diff --git a/.appveyor.yml b/.appveyor.yml deleted file mode 100644 index c2e095f9be51..000000000000 --- a/.appveyor.yml +++ /dev/null @@ -1,19 +0,0 @@ -environment: - matrix: - - nodejs_version: "6.0" - -matrix: - fast_finish: true - -install: - - ps: Install-Product node $env:nodejs_version - - npm install -g npm - - npm install - -test_script: - - node --version - - npm --version - - npm test - - node tests\e2e_runner.js - -build: off 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/.editorconfig b/.editorconfig index e0f1dae3a7d9..c0a70d2acb14 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,14 +1,16 @@ -# http://editorconfig.org +# https://editorconfig.org root = true -[*] +[*.ts] charset = utf-8 indent_style = space indent_size = 2 insert_final_newline = true +spaces_around_brackets = inside trim_trailing_whitespace = true +quote_type = single [*.md] insert_final_newline = false -trim_trailing_whitespace = false +trim_trailing_whitespace = true diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 974911dfbb03..000000000000 --- a/.eslintignore +++ /dev/null @@ -1,10 +0,0 @@ -dist/ -.git/ -tmp/ -typings/ - -# Ignore all blueprint files. We e2e tests those later on. -packages/angular-cli/blueprints/*/files/ - -# Ignore ember cli. -packages/angular-cli/ember-cli/ \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 3ccede5e4b32..000000000000 --- a/.eslintrc.json +++ /dev/null @@ -1,227 +0,0 @@ -{ - "ecmaFeatures": { - "modules": true, - "module": true - }, - "env": { - "mocha": true, - "node": true, - "es6": true - }, - "parser": "espree", - "rules": { - "no-alert": "off", - "no-array-constructor": "off", - "no-bitwise": "off", - "no-caller": "off", - "no-case-declarations": "error", - "no-catch-shadow": "off", - "no-class-assign": "error", - "no-cond-assign": "error", - "no-confusing-arrow": "off", - "no-console": "error", - "no-const-assign": "error", - "no-constant-condition": "error", - "no-continue": "off", - "no-control-regex": "error", - "no-debugger": "error", - "no-delete-var": "error", - "no-div-regex": "off", - "no-dupe-class-members": "error", - "no-dupe-keys": "error", - "no-dupe-args": "error", - "no-duplicate-case": "error", - "no-duplicate-imports": "off", - "no-else-return": "off", - "no-empty": "error", - "no-empty-character-class": "error", - "no-empty-function": "off", - "no-empty-pattern": "error", - "no-eq-null": "off", - "no-eval": "off", - "no-ex-assign": "error", - "no-extend-native": "off", - "no-extra-bind": "off", - "no-extra-boolean-cast": "error", - "no-extra-label": "off", - "no-extra-parens": "off", - "no-extra-semi": "error", - "no-fallthrough": "error", - "no-floating-decimal": "off", - "no-func-assign": "error", - "no-implicit-coercion": "off", - "no-implicit-globals": "off", - "no-implied-eval": "off", - "no-inline-comments": "off", - "no-inner-declarations": "error", - "no-invalid-regexp": "error", - "no-invalid-this": "off", - "no-irregular-whitespace": "error", - "no-iterator": "off", - "no-label-var": "off", - "no-labels": "off", - "no-lone-blocks": "off", - "no-lonely-if": "off", - "no-loop-func": "off", - "no-mixed-requires": "off", - "no-mixed-spaces-and-tabs": "error", - "linebreak-style": "off", - "no-multi-spaces": "off", - "no-multi-str": "off", - "no-multiple-empty-lines": "off", - "no-native-reassign": "off", - "no-negated-condition": "off", - "no-negated-in-lhs": "error", - "no-nested-ternary": "off", - "no-new": "off", - "no-new-func": "off", - "no-new-object": "off", - "no-new-require": "off", - "no-new-symbol": "error", - "no-new-wrappers": "off", - "no-obj-calls": "error", - "no-octal": "error", - "no-octal-escape": "off", - "no-param-reassign": "off", - "no-path-concat": "off", - "no-plusplus": "off", - "no-process-env": "off", - "no-process-exit": "off", - "no-proto": "off", - "no-redeclare": "error", - "no-regex-spaces": "error", - "no-restricted-globals": "off", - "no-restricted-imports": "off", - "no-restricted-modules": "off", - "no-restricted-syntax": "off", - "no-return-assign": "off", - "no-script-url": "off", - "no-self-assign": "error", - "no-self-compare": "off", - "no-sequences": "off", - "no-shadow": "off", - "no-shadow-restricted-names": "off", - "no-whitespace-before-property": "off", - "no-spaced-func": "off", - "no-sparse-arrays": "error", - "no-sync": "off", - "no-ternary": "off", - "no-trailing-spaces": "off", - "no-this-before-super": "error", - "no-throw-literal": "off", - "no-undef": "error", - "no-undef-init": "off", - "no-undefined": "off", - "no-unexpected-multiline": "error", - "no-underscore-dangle": "off", - "no-unmodified-loop-condition": "off", - "no-unneeded-ternary": "off", - "no-unreachable": "error", - "no-unused-expressions": "off", - "no-unused-labels": "error", - "no-unused-vars": "error", - "no-use-before-define": "off", - "no-useless-call": "off", - "no-useless-concat": "off", - "no-useless-constructor": "off", - "no-void": "off", - "no-var": "off", - "no-warning-comments": "off", - "no-with": "off", - "no-magic-numbers": "off", - "array-bracket-spacing": "off", - "array-callback-return": "off", - "arrow-body-style": "off", - "arrow-parens": "off", - "arrow-spacing": "off", - "accessor-pairs": "off", - "block-scoped-var": "off", - "block-spacing": "off", - "brace-style": [2, "1tbs", { "allowSingleLine": false }], - "callback-return": "off", - "camelcase": "off", - "comma-dangle": "error", - "comma-spacing": "off", - "comma-style": "off", - "complexity": ["off", 11], - "computed-property-spacing": "off", - "consistent-return": "off", - "consistent-this": "off", - "constructor-super": "error", - "curly": "off", - "default-case": "off", - "dot-location": "off", - "dot-notation": "off", - "eol-last": "off", - "eqeqeq": "off", - "func-names": "off", - "func-style": "off", - "generator-star-spacing": "off", - "global-require": "off", - "guard-for-in": "off", - "handle-callback-err": "off", - "id-length": "off", - "indent": [2,2], - "init-declarations": "off", - "jsx-quotes": "off", - "key-spacing": [2, { - "multiLine": { - "beforeColon": false, - "afterColon": true, - "mode": "minimum" - } - }], - "keyword-spacing": "off", - "lines-around-comment": "off", - "max-depth": "off", - "max-len": "off", - "max-nested-callbacks": "off", - "max-params": "off", - "max-statements": "off", - "max-statements-per-line": "off", - "new-cap": "off", - "new-parens": "off", - "newline-after-var": "off", - "newline-before-return": "off", - "newline-per-chained-call": "off", - "object-curly-spacing": [2, "always"], - "object-shorthand": "off", - "one-var": "off", - "one-var-declaration-per-line": "off", - "operator-assignment": "off", - "operator-linebreak": "off", - "padded-blocks": "off", - "prefer-arrow-callback": "off", - "prefer-const": "off", - "prefer-reflect": "off", - "prefer-rest-params": "off", - "prefer-spread": "off", - "prefer-template": "off", - "quote-props": "off", - "quotes": [2, "single"], - "radix": "off", - "id-match": "off", - "id-blacklist": "off", - "require-jsdoc": "off", - "require-yield": "off", - "semi-spacing": "off", - "sort-vars": "off", - "sort-imports": "off", - "space-before-blocks": "off", - "space-before-function-paren": "off", - "space-in-parens": "off", - "space-infix-ops": "off", - "space-unary-ops": "off", - "spaced-comment": "off", - "strict": "off", - "template-curly-spacing": "off", - "use-isnan": "error", - "valid-jsdoc": "off", - "valid-typeof": "error", - "vars-on-top": "off", - "wrap-iife": "off", - "wrap-regex": "off", - "yield-star-spacing": "off", - "yoda": "off" - } -} diff --git a/.gemini/config.yaml b/.gemini/config.yaml new file mode 100644 index 000000000000..795367b13a53 --- /dev/null +++ b/.gemini/config.yaml @@ -0,0 +1,13 @@ +have_fun: false +code_review: + disable: false + comment_severity_threshold: MEDIUM + max_review_comments: -1 + pull_request_opened: + help: false + summary: false + code_review: true + include_drafts: false +ignore_patterns: + - pnpm-lock.yaml + - CHANGELOG.md diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000000..de32b85d6693 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +# Auto detect text files and perform LF normalization +* text=auto + +# JS and TS files must always use LF for tools to work +*.js eol=lf +*.ts eol=lf +*.json eol=lf +*.css eol=lf +*.scss eol=lf +*.less eol=lf +*.html eol=lf +*.svg eol=lf diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index fa7c45bbb104..000000000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,23 +0,0 @@ -> Please provide us with the following information: -> --------------------------------------------------------------- - -### OS? -> Windows 7, 8 or 10. Linux (which distribution). Mac OSX (Yosemite? El Capitan?) - - -### Versions. -> Please run `ng --version`. If there's nothing outputted, please run in a Terminal: `node --version` and paste the result here: - - -### Repro steps. -> Was this an app that wasn't created using the CLI? What change did you do on your code? etc. - - -### The log given by the failure. -> Normally this include a stack trace and some more information. - - -### Mention any other details that might be useful. - -> --------------------------------------------------------------- -> Thanks! We'll be in touch soon. 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 new file mode 100644 index 000000000000..dfce61802c0e --- /dev/null +++ b/.github/SAVED_REPLIES.md @@ -0,0 +1,111 @@ +# Saved Responses for Angular CLI's Issue Tracker + +The following are canned responses that the Angular CLI team should use to close issues on our issue tracker that fall into the listed resolution categories. + +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/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/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](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 debug these errors: +- https://gist.github.com/chuckjaz/65dcc2fd5f4f5463e492ed0cb93bca60 +- https://github.com/rangle/angular-2-aot-sandbox#aot-dos-and-donts + +If your problem still persists, it might be a bug with the Angular Compiler itself. +In that case, please open an issue in https://github.com/angular/angular. +``` + +## Angular CLI: Lockfiles (v1) + +``` +I'd like to remind everyone that **you only have reproducible installs if you use a lockfile**. Both [NPM v5+](https://docs.npmjs.com/files/package-locks) and [Yarn](https://yarnpkg.com/lang/en/docs/yarn-lock/) support lockfiles. If your CI works one day but not the next and you did not change your code or `package.json`, it is likely because one of your dependencies had a bad release and you did not have a lockfile. + +**It is your responsibility as a library consumer to use lockfiles**. No one wants to do a release with bugs but it sometimes happens, and the best we can do is to fix it as fast as possible with a new release. When you have a couple of thousand total dependencies it is only a matter of time until one of them has a bad release. +``` + +## Angular CLI: Spam (v1) + +``` +Woah, looks like you've opened a lot of issues/PRs recently. While we appreciate contributions from the community, triaging and reviewing a large influx of content in a short time period takes time away from other ongoing projects. As a result, we're closing these issues/PRs to maintain the team's focus. + +Note that this is not necessarily a rejection of the goals or direction of any of these contributions in particular, so much as a reflection of the team's current capacity and priorities. + +You are welcome to open a smaller subset of issues/PRs in accordance with [our policy](https://github.com/angular/angular/blob/main/contributing-docs/spam.md) focused on the most important and impactful contributions and we will do our best to prioritize a response as soon as possible. +``` diff --git a/.github/angular-robot.yml b/.github/angular-robot.yml new file mode 100644 index 000000000000..3e3a56a25603 --- /dev/null +++ b/.github/angular-robot.yml @@ -0,0 +1,92 @@ +# Configuration for angular-robot + +# options for the merge plugin +merge: + # the status will be added to your pull requests + status: + # set to true to disable + disabled: true + # the name of the status + context: 'ci/angular: merge status' + # text to show when all checks pass + successText: 'All checks passed!' + # text to show when some checks are failing + failureText: 'The following checks are failing:' + + # comment that will be added to a PR when there is a conflict, leave empty or set to false to disable + mergeConflictComment: >- + Hi @{{PRAuthor}}! This PR has merge conflicts due to recent upstream merges. + + Please help to unblock it by resolving these conflicts. Thanks! + + # label to monitor + mergeLabel: 'action: merge' + + # list of checks that will determine if the merge label can be added + checks: + # whether the PR shouldn't have a conflict with the base branch + noConflict: true + # whether the PR should have all reviews completed. + requireReviews: true + # list of labels that a PR needs to have, checked with a regexp (e.g. "target:" will work for the label "target: major") + requiredLabels: + - 'target: *' + + # list of labels that a PR shouldn't have, checked after the required labels with a regexp + forbiddenLabels: + - 'action: cleanup' + - 'action: review' + - 'PR state: blocked' + + # list of PR statuses that need to be successful + # NOTE: Required PR statuses are now exclusively handled via Github configuration + requiredStatuses: [] + + # the comment that will be added when the merge label is added despite failing checks, leave empty or set to false to disable + # {{MERGE_LABEL}} will be replaced by the value of the mergeLabel option + # {{PLACEHOLDER}} will be replaced by the list of failing checks + mergeRemovedComment: >- + I see that you just added the `{{MERGE_LABEL}}` label, but the following + checks are still failing: + + {{PLACEHOLDER}} + + **If you want your PR to be merged, it has to pass all the CI checks.** + + If you can't get the PR to a green state due to flakes or broken `main`, + please try rebasing to `main` and/or restarting the CI job. If that fails + and you believe that the issue is not due to your change, please contact the + caretaker and ask for help. +# options for the triage plugin +triage: + # set to true to disable + disabled: true + # number of the milestone to apply when the issue has not been triaged yet + needsTriageMilestone: 11, + # number of the milestone to apply when the issue is triaged + defaultMilestone: 12, + # arrays of labels that determine if an issue has been triaged by the caretaker + l1TriageLabels: + - - 'area: *' + # arrays of labels that determine if an issue has been fully triaged + l2TriageLabels: + - - 'type: bug/fix' + - 'severity*' + - 'freq*' + - 'area: *' + - - 'type: feature' + - 'area: *' + - - 'type: refactor' + - 'area: *' + - - 'type: RFC / Discussion / question' + - 'area: *' + - - 'type: docs' + - 'area: *' + +# Size checking +size: + # Size checking for production build is performed via the E2E test `build/prod-build` + disabled: true + circleCiStatusName: 'ci/circleci: e2e-cli' + maxSizeIncrease: 10000 + comment: false diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000000..a01353b6bcf0 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 + +updates: + - package-ecosystem: 'npm' + directory: '/' + schedule: + interval: 'daily' + commit-message: + prefix: 'build' + labels: + - 'area: build & ci' + - 'target: patch' + - 'action: merge' + # Disable version updates + # This does not affect security updates + open-pull-requests-limit: 0 diff --git a/.github/shared-actions/windows-bazel-test/action.yml b/.github/shared-actions/windows-bazel-test/action.yml new file mode 100644 index 000000000000..7a5cf7c0297b --- /dev/null +++ b/.github/shared-actions/windows-bazel-test/action.yml @@ -0,0 +1,42 @@ +name: Native Windows Bazel E2E test +description: Runs an Angular CLI e2e Bazel test on native Windows +author: Angular + +inputs: + test_target_name: + description: E2E test target name. + required: true + e2e_temp_dir: + description: 'The temporary directory path for E2E tests.' + required: false + # Use D:\\ by default as it's much faster + # See: https://devblogs.microsoft.com/commandline/share-environment-vars-between-wsl-and-windows + default: 'D:\\tmp_dir' + +runs: + using: 'composite' + steps: + - name: Set up temp directory + shell: bash + run: | + mkdir ${{ inputs.e2e_temp_dir }} + + - name: Convert symlinks for Windows host + shell: pwsh + run: | + $runfiles_dir = "./dist/bin/tests/${{inputs.test_target_name}}_/${{inputs.test_target_name}}.bat.runfiles" + + # Needed for resolution because Aspect/Bazel looks for repositories at `/external`. + # TODO(devversion): consult with Aspect on why this is needed. + Set-Location -Path "${runfiles_dir}\_main" + New-Item -ItemType SymbolicLink -Path "external" -Target ".." + + - name: Run CLI E2E tests + shell: bash + env: + BAZEL_BINDIR: '.' + E2E_TEMP: ${{ inputs.e2e_temp_dir }} + run: | + node ./scripts/windows-testing/parallel-executor.mjs \ + "./dist/bin/tests/${{ inputs.test_target_name }}_/${{ inputs.test_target_name }}.bat.runfiles" \ + ${{ inputs.test_target_name }} diff --git a/.github/workflows/assistant-to-the-branch-manager.yml b/.github/workflows/assistant-to-the-branch-manager.yml new file mode 100644 index 000000000000..0a037e29ddfc --- /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@d45884cba93817d0e1a2be67ca5638227f64e508 # 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..27f69c12de38 --- /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@d45884cba93817d0e1a2be67ca5638227f64e508 # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@d45884cba93817d0e1a2be67ca5638227f64e508 # 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@d45884cba93817d0e1a2be67ca5638227f64e508 # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@d45884cba93817d0e1a2be67ca5638227f64e508 # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@d45884cba93817d0e1a2be67ca5638227f64e508 # 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@d45884cba93817d0e1a2be67ca5638227f64e508 # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@d45884cba93817d0e1a2be67ca5638227f64e508 # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@d45884cba93817d0e1a2be67ca5638227f64e508 # 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@d45884cba93817d0e1a2be67ca5638227f64e508 # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@d45884cba93817d0e1a2be67ca5638227f64e508 # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@d45884cba93817d0e1a2be67ca5638227f64e508 # 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@d45884cba93817d0e1a2be67ca5638227f64e508 # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@d45884cba93817d0e1a2be67ca5638227f64e508 # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@d45884cba93817d0e1a2be67ca5638227f64e508 # 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@d45884cba93817d0e1a2be67ca5638227f64e508 # 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@d45884cba93817d0e1a2be67ca5638227f64e508 # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@d45884cba93817d0e1a2be67ca5638227f64e508 # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@d45884cba93817d0e1a2be67ca5638227f64e508 # 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@d45884cba93817d0e1a2be67ca5638227f64e508 # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@d45884cba93817d0e1a2be67ca5638227f64e508 # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@d45884cba93817d0e1a2be67ca5638227f64e508 # 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@d45884cba93817d0e1a2be67ca5638227f64e508 # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@d45884cba93817d0e1a2be67ca5638227f64e508 # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@d45884cba93817d0e1a2be67ca5638227f64e508 # 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@d45884cba93817d0e1a2be67ca5638227f64e508 # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@d45884cba93817d0e1a2be67ca5638227f64e508 # 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..629c8e1a1863 --- /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@d45884cba93817d0e1a2be67ca5638227f64e508 # 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@d45884cba93817d0e1a2be67ca5638227f64e508 # 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@d45884cba93817d0e1a2be67ca5638227f64e508 # 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..779b42915aa7 --- /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@d45884cba93817d0e1a2be67ca5638227f64e508 # 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@d45884cba93817d0e1a2be67ca5638227f64e508 # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@d45884cba93817d0e1a2be67ca5638227f64e508 # 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..b2d1a36a4eca --- /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@d45884cba93817d0e1a2be67ca5638227f64e508 # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@d45884cba93817d0e1a2be67ca5638227f64e508 # 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@d45884cba93817d0e1a2be67ca5638227f64e508 # main + + build: + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@d45884cba93817d0e1a2be67ca5638227f64e508 # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@d45884cba93817d0e1a2be67ca5638227f64e508 # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@d45884cba93817d0e1a2be67ca5638227f64e508 # 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@d45884cba93817d0e1a2be67ca5638227f64e508 # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@d45884cba93817d0e1a2be67ca5638227f64e508 # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@d45884cba93817d0e1a2be67ca5638227f64e508 # 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@d45884cba93817d0e1a2be67ca5638227f64e508 # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@d45884cba93817d0e1a2be67ca5638227f64e508 # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@d45884cba93817d0e1a2be67ca5638227f64e508 # 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@d45884cba93817d0e1a2be67ca5638227f64e508 # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@d45884cba93817d0e1a2be67ca5638227f64e508 # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@d45884cba93817d0e1a2be67ca5638227f64e508 # 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@d45884cba93817d0e1a2be67ca5638227f64e508 # 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@d45884cba93817d0e1a2be67ca5638227f64e508 # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@d45884cba93817d0e1a2be67ca5638227f64e508 # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@d45884cba93817d0e1a2be67ca5638227f64e508 # 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@d45884cba93817d0e1a2be67ca5638227f64e508 # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@d45884cba93817d0e1a2be67ca5638227f64e508 # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@d45884cba93817d0e1a2be67ca5638227f64e508 # 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 34424b90ae12..83582f8ece43 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,59 @@ +# Outputs +bazel-* +test-project-host-* dist/ -node_modules/ -npm-debug.log* +dist-schema/ + +# Yarn +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions # IDEs -.idea/ jsconfig.json -.vscode/ + +# Intellij IDEA/WebStorm +# https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 +.idea/inspectionProfiles/ +.idea/**/compiler.xml +.idea/**/encodings.xml +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Also ignore code styles because .editorconfig is used instead. +.idea/codeStyles/ + +# VSCode +# https://github.com/github/gitignore/blob/master/Global/VisualStudioCode.gitignore +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +**/*.code-workspace # Typings file. typings/ # Misc +coverage/ +node_modules/ tmp/ +npm-debug.log* +yarn-error.log* +.ng_pkg_build/ +.ng-dev.log +.ng-dev.err*.log +.ng-dev.user* +.husky/_ +.bazelrc.user +.eslintcache # Mac OSX Finder files. **/.DS_Store diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 000000000000..de361903a541 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +pnpm --silent ng-dev commit-message pre-commit-validate --file $1; diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 000000000000..7f9d46b47835 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +pnpm --silent ng-dev format staged; diff --git a/.husky/prepare-commit-msg b/.husky/prepare-commit-msg new file mode 100755 index 000000000000..f4598b6d1b6e --- /dev/null +++ b/.husky/prepare-commit-msg @@ -0,0 +1 @@ +pnpm --silent ng-dev commit-message restore-commit-message-draft $1 $2; diff --git a/.mailmap b/.mailmap new file mode 100644 index 000000000000..f651b93dd40b --- /dev/null +++ b/.mailmap @@ -0,0 +1,54 @@ +# Please keep this file in alphabetical order, per section. + + +################################################################################ +# Angular Team +################################################################################ +Hans Larsen +Igor Minar + Igor Minar + + +################################################################################ +# Angular CLI Team +################################################################################ +Charles Lyding + Charles Lyding <19598772+clydin@users.noreply.github.com> +Filipe Silva +Mike Brocchi +Alan Agius <17563226+alan-agius4@users.noreply.github.com> + Alan Agius <17563226+alan-agius4@users.noreply.github.com> + Alan Agius <17563226+alan-agius4@users.noreply.github.com> + Alan Agius <17563226+alan-agius4@users.noreply.github.com> + + +################################################################################ +# OpenSource Contributors +################################################################################ +Ashok Tamang + Ashok Tamang +Bram Borggreve +Carlo Dapor + +Cédric Exbrayat + Cédric Exbrayat +Chris Pearce + Chris Pearce +Dominic Watson + Dominic Watson +Jeremy Wells +Jan Kuri +Jim Cummins + Jim Cummins +John Papa + John Papa +Meligy +Michał Gołębiowski-Owczarek +Preston Van Loon + Preston Van Loon +Rodric Haddad +Shai Reznik + Shai Reznik + Shai Reznik +Stephen Cavaliere + Stephen Cavaliere diff --git a/.monorepo.json b/.monorepo.json new file mode 100644 index 000000000000..ad86e09acdeb --- /dev/null +++ b/.monorepo.json @@ -0,0 +1,118 @@ +{ + "packages": { + "@_/builders": {}, + "devkit": {}, + "@angular/build": { + "name": "Angular Build System", + "section": "Tooling", + "links": [ + { + "label": "README", + "url": "/packages/angular/build/README.md" + } + ], + "snapshotRepo": "angular/angular-build-builds" + }, + "@angular/cli": { + "name": "Angular CLI", + "section": "Tooling", + "links": [ + { + "label": "README", + "url": "/packages/angular/cli/README.md" + } + ], + "snapshotRepo": "angular/cli-builds" + }, + "@angular/create": { + "name": "Angular Create", + "section": "Misc", + "links": [ + { + "label": "README", + "url": "/packages/angular/create/README.md" + } + ] + }, + "@angular/pwa": { + "name": "Angular PWA Schematics", + "section": "Schematics", + "snapshotRepo": "angular/angular-pwa-builds" + }, + "@angular/ssr": { + "name": "Angular SSR", + "links": [ + { + "label": "README", + "url": "/packages/angular/ssr/README.md" + } + ], + "snapshotRepo": "angular/angular-ssr-builds" + }, + "@angular-devkit/architect": { + "name": "Architect", + "links": [ + { + "label": "README", + "url": "/packages/angular_devkit/architect/README.md" + } + ], + "snapshotRepo": "angular/angular-devkit-architect-builds" + }, + "@angular-devkit/build-angular": { + "name": "Build Angular", + "links": [ + { + "label": "README", + "url": "/packages/angular_devkit/build_angular/README.md" + } + ], + "snapshotRepo": "angular/angular-devkit-build-angular-builds" + }, + "@angular-devkit/build-webpack": { + "name": "Build Webpack", + "links": [ + { + "label": "README", + "url": "/packages/angular_devkit/build_webpack/README.md" + } + ], + "snapshotRepo": "angular/angular-devkit-build-webpack-builds" + }, + "@angular-devkit/core": { + "name": "Core", + "links": [ + { + "label": "README", + "url": "/packages/angular_devkit/core/README.md" + } + ], + "snapshotRepo": "angular/angular-devkit-core-builds" + }, + "@angular-devkit/schematics": { + "name": "Schematics", + "links": [ + { + "label": "README", + "url": "/packages/angular_devkit/schematics/README.md" + } + ], + "snapshotRepo": "angular/angular-devkit-schematics-builds" + }, + "@angular-devkit/schematics-cli": { + "name": "Schematics CLI", + "section": "Tooling", + "snapshotRepo": "angular/angular-devkit-schematics-cli-builds" + }, + "@ngtools/webpack": { + "name": "Webpack Angular Plugin", + "section": "Misc", + "snapshotRepo": "angular/ngtools-webpack-builds" + }, + "@schematics/angular": { + "name": "Angular Schematics", + "section": "Schematics", + "snapshotRepo": "angular/schematics-angular-builds" + } + } +} diff --git a/.ng-dev/caretaker.mjs b/.ng-dev/caretaker.mjs new file mode 100644 index 000000000000..d21e783f4af5 --- /dev/null +++ b/.ng-dev/caretaker.mjs @@ -0,0 +1,17 @@ +/** + * The configuration for `ng-dev caretaker` commands. + * + * @type { import("@angular/ng-dev").CaretakerConfig } + */ +export const caretaker = { + githubQueries: [ + { + name: 'Merge Queue', + query: `is:pr is:open status:success label:"action: merge"`, + }, + { + name: 'Merge Assistance Queue', + query: `is:pr is:open label:"action: merge-assistance"`, + }, + ], +}; diff --git a/.ng-dev/commit-message.mjs b/.ng-dev/commit-message.mjs new file mode 100644 index 000000000000..d30060674355 --- /dev/null +++ b/.ng-dev/commit-message.mjs @@ -0,0 +1,14 @@ +import { packages } from '../scripts/packages.mts'; + +/** + * The configuration for `ng-dev commit-message` commands. + * + * @type { import("@angular/ng-dev").CommitMessageConfig } + */ +export const commitMessage = { + maxLineLength: Infinity, + minBodyLength: 0, + minBodyLengthTypeExcludes: ['docs'], + // Note: When changing this logic, also change the `contributing.ejs` file. + scopes: packages.map(({ name }) => name), +}; diff --git a/.ng-dev/config.mjs b/.ng-dev/config.mjs new file mode 100644 index 000000000000..6add9773c06c --- /dev/null +++ b/.ng-dev/config.mjs @@ -0,0 +1,6 @@ +export { commitMessage } from './commit-message.mjs'; +export { format } from './format.mjs'; +export { github } from './github.mjs'; +export { pullRequest } from './pull-request.mjs'; +export { release } from './release.mjs'; +export { caretaker } from './caretaker.mjs'; diff --git a/.ng-dev/dx-perf-workflows.yml b/.ng-dev/dx-perf-workflows.yml new file mode 100644 index 000000000000..70960ff760db --- /dev/null +++ b/.ng-dev/dx-perf-workflows.yml @@ -0,0 +1,49 @@ +workflows: + build-cli: + name: '@angular/cli build' + prepare: + - bazel clean + workflow: + - bazel build //packages/angular/cli:npm_package + + angular-build-integration: + name: '@angular/build integration' + disabled: true + prepare: + - bazel clean + workflow: + - bazel test //packages/angular/build:integration_tests + + modules-builder-tests: + name: '@ngtools/webpack test' + prepare: + - bazel clean + workflow: + - bazel test //packages/ngtools/webpack:test + + devkit-core-tests: + name: '@angular/devkit/core tests' + prepare: + - bazel clean + workflow: + - bazel test //packages/angular_devkit/core:test + + devkit-core-tests-rerun: + name: '@angular/devkit/core return test' + prepare: + - bazel clean + workflow: + - bazel test //packages/angular_devkit/core:test + # Add a single line to the beginning of a file to trigger a rebuild/retest + - sed -i '1i // comment' packages/angular_devkit/core/src/workspace/core_spec.ts + - bazel test //packages/angular_devkit/core:test + cleanup: + # Remove the single line added + - sed -i '1d' packages/angular_devkit/core/src/workspace/core_spec.ts + + build-unit-tests: + name: '@angular/build tests' + prepare: + - bazel clean + workflow: + - bazel test //packages/angular/build:test diff --git a/.ng-dev/format.mjs b/.ng-dev/format.mjs new file mode 100644 index 000000000000..c750eeea395c --- /dev/null +++ b/.ng-dev/format.mjs @@ -0,0 +1,9 @@ +/** + * Configuration for the `ng-dev format` command. + * + * @type { import("@angular/ng-dev").FormatConfig } + */ +export const format = { + 'prettier': true, + 'buildifier': true, +}; diff --git a/.ng-dev/github.mjs b/.ng-dev/github.mjs new file mode 100644 index 000000000000..467741f70f83 --- /dev/null +++ b/.ng-dev/github.mjs @@ -0,0 +1,13 @@ +/** + * Github configuration for the ng-dev command. This repository is + * used as remote for the merge script. + * + * @type { import("@angular/ng-dev").GithubConfig } + */ +export const github = { + owner: 'angular', + name: 'angular-cli', + mainBranchName: 'main', + useNgDevAuthService: true, + mergeMode: 'team-only', +}; diff --git a/.ng-dev/pull-request.mjs b/.ng-dev/pull-request.mjs new file mode 100644 index 000000000000..1f2d84d7ccba --- /dev/null +++ b/.ng-dev/pull-request.mjs @@ -0,0 +1,12 @@ +/** + * Configuration for the merge tool in `ng-dev`. This sets up the labels which + * are respected by the merge script (e.g. the target labels). + * + * @type { import("@angular/ng-dev").PullRequestConfig } + */ +export const pullRequest = { + githubApiMerge: { + default: 'auto', + labels: [{ pattern: 'merge: squash commits', method: 'squash' }], + }, +}; diff --git a/.ng-dev/release.mjs b/.ng-dev/release.mjs new file mode 100644 index 000000000000..19e111f22f80 --- /dev/null +++ b/.ng-dev/release.mjs @@ -0,0 +1,48 @@ +import semver from 'semver'; +import { releasePackages } from '../scripts/packages.mts'; + +/** + * Configuration for the `ng-dev release` command. + * + * @type { import("@angular/ng-dev").ReleaseConfig } + */ +export const release = { + representativeNpmPackage: '@angular/cli', + npmPackages: releasePackages.map(({ name, experimental }) => { + if ( + name === '@angular-devkit/build-angular' || + name === '@angular-devkit/build-webpack' || + name === '@ngtools/webpack' + ) { + return { + name, + experimental, + deprecated: { + version: '>=22.0.0-rc.0', + message: + 'Angular\'s Webpack support is deprecated. Use the esbuild and Vite-based "@angular/build" package instead.', + }, + }; + } + + return { name, experimental }; + }), + buildPackages: async () => { + // The `performNpmReleaseBuild` function is loaded at runtime to avoid loading additional + // files and dependencies unless a build is required. + const { performNpmReleaseBuild } = await import('../scripts/build-packages-dist.mts'); + return performNpmReleaseBuild(); + }, + prereleaseCheck: async (newVersionStr) => { + const newVersion = new semver.SemVer(newVersionStr); + const { assertValidDependencyRanges } = + await import('../scripts/release-checks/dependency-ranges/index.mts'); + + await assertValidDependencyRanges(newVersion, releasePackages); + }, + releaseNotes: { + groupOrder: ['@angular/cli', '@schematics/angular', '@angular-devkit/schematics-cli'], + }, + publishRegistry: 'https://wombat-dressing-room.appspot.com', + releasePrLabels: ['action: merge'], +}; diff --git a/.ng-dev/tsconfig.json b/.ng-dev/tsconfig.json new file mode 100644 index 000000000000..a250849eb673 --- /dev/null +++ b/.ng-dev/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "resolveJsonModule": true, + "allowJs": true, + "rewriteRelativeImportExtensions": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "module": "Node16", + "moduleResolution": "Node16", + "checkJs": true, + "noEmit": true, + "types": [] + }, + "include": ["**/*.mjs"], + "exclude": [] +} diff --git a/.nvmrc b/.nvmrc index b8626c4cff28..b74495a07da3 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -4 +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/.travis.yml b/.travis.yml deleted file mode 100644 index f09e83ca2e5c..000000000000 --- a/.travis.yml +++ /dev/null @@ -1,66 +0,0 @@ -dist: trusty -sudo: required - -language: node_js - -env: - global: - - DBUS_SESSION_BUS_ADDRESS=/dev/null - -matrix: - fast_finish: true - allow_failures: - - os: osx - - node_js: "7" - - env: NODE_SCRIPT="tests/e2e_runner.js --nightly" - include: - - node_js: "6" - os: linux - env: SCRIPT=lint - - node_js: "6" - os: linux - env: SCRIPT=build - - node_js: "4" - os: linux - env: NODE_SCRIPT=tests/e2e_runner.js - - node_js: "6" - os: linux - env: SCRIPT=test - - node_js: "6" - os: linux - env: NODE_SCRIPT=tests/e2e_runner.js - - node_js: "6" - os: osx - env: NODE_SCRIPT=tests/e2e_runner.js - - # Optional builds. - - node_js: "6" - os: osx - env: SCRIPT=test - - node_js: "6" - os: linux - env: NODE_SCRIPT="tests/e2e_runner.js --nightly" - - node_js: "7" - os: linux - env: NODE_SCRIPT=tests/e2e_runner.js - -before_install: - - if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then brew update; fi - - if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then brew tap caskroom/cask; fi - - if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then brew cask install google-chrome --force; fi - - if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then mkdir ~/.config && echo "--no-sandbox" > ~/.config/chrome-flags.conf; fi - - if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then export DISPLAY=:99.0; fi - - if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then sh -e /etc/init.d/xvfb start; fi - - if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then export CHROME_BIN=chromium-browser; fi - - if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then echo "--no-sandbox" > ~/.config/chromium-flags.conf; fi - - if [[ "$TARGET" == "mobile" ]]; then export MOBILE_TEST=true; fi - - npm install -g npm - - npm config set spin false - - npm config set progress false - -install: - - npm install --no-optional - -script: - - if [[ "$SCRIPT" ]]; then npm run-script $SCRIPT; fi - - if [[ "$NODE_SCRIPT" ]]; then node $NODE_SCRIPT; fi diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000000..6f3e0e3d3375 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,49 @@ +// A launch configuration that compiles the extension and then opens it inside a new window +{ + "version": "0.2.0", + "configurations": [ + { + "name": "DEBUG: Attach to bazel test", + "type": "node", + "request": "attach", + "port": 9229, + "restart": true, + "timeout": 600000, + "sourceMaps": true, + "skipFiles": ["/**"], + "sourceMapPathOverrides": { + "?:*@0.0.0/node_modules/@angular-devkit/build-angular/*": "${workspaceFolder}/packages/angular_devkit/build_angular/*", + "?:*@0.0.0/node_modules/@angular-devkit/build-webpack/*": "${workspaceFolder}/packages/angular_devkit/build_webpack/*", + "?:*@0.0.0/node_modules/@angular-devkit/*": "${workspaceFolder}/packages/angular_devkit/*", + "?:*@0.0.0/node_modules/@angular/*": "${workspaceFolder}/packages/angular/*", + "?:*/bin/*": "${workspaceFolder}/*" + }, + "resolveSourceMapLocations": ["*/**", "!**/rxjs**"] + }, + { + "name": "DEBUG: Run bazel test (Custom Target)", + "type": "node", + "request": "launch", + "restart": true, + "timeout": 600000, + "runtimeExecutable": "pnpm", + "runtimeArgs": ["bazel", "test", "${input:bazelTarget}", "--config=debug"], + "console": "integratedTerminal", + "cwd": "${workspaceFolder}" + } + ], + "compounds": [ + { + "name": "DEBUG: Run bazel test (Custom Target) + Attach", + "configurations": ["DEBUG: Attach to bazel test", "DEBUG: Run bazel test (Custom Target)"] + } + ], + "inputs": [ + { + "id": "bazelTarget", + "type": "promptString", + "description": "Enter the Bazel test target (e.g., //path/to/my:unit_test)", + "default": "//packages/..." + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000000..b98a874af297 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,26 @@ +{ + "[javascript]": { + "editor.formatOnSave": true + }, + "[typescript]": { + "editor.formatOnSave": true + }, + // Exclude third party modules and build artifacts from the editor watchers/searches. + "files.watcherExclude": { + "**/.git/objects/**": true, + "**/.git/subtree-cache/**": true, + "**/node_modules/**": true, + "**/bazel-out/**": true, + "**/dist/**": true, + "**/dist-schema/**": true + }, + "search.exclude": { + "**/node_modules": true, + "**/bazel-out": true, + "**/dist": true, + "**/dist-schema": true, + ".history": true + }, + "git.ignoreLimitWarning": true, + "gitlens.advanced.blame.customArguments": ["--ignore-revs-file .git-blame-ignore-revs"] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000000..7ed3ec6a1316 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,77 @@ +--- +trigger: always_on +--- + +This is the source code for the Angular CLI and related build tooling. This guide outlines standard practices for AI agents working in this repository. + +## Environment + +- Use `pnpm` for package management. +- Use `pnpm bazel test //target` to run tests. + +## Key Documentation + +- [Developer Guide](docs/DEVELOPER.md): definitive guide for building, debugging, and running test targets. +- [Contributing Guide](CONTRIBUTING.md): general contribution workflows and guidelines. +- [Commit Guidelines](CONTRIBUTING.md#commit): format for commit messages and PR titles. + +## Building + +- Make a local build of all packages: + ```shell + pnpm build --local + ``` + +## Coding Practices + +- **Imports:** + - Always use the `node:` protocol for Node.js built-in imports (e.g., `node:fs`, `node:path`, `node:assert`). + - Prefer named imports (e.g., `import { mkdtemp } from 'node:fs'`) or default imports (`import fs from 'node:fs'`) instead of namespace imports (`import * as fs`). + - Use type-only imports (`import type { ... }`) when importing types to avoid runtime side-effects. +- **Classes:** + - Prefer ECMAScript private fields (`#field`) over TypeScript `private` keywords for encapsulated state. + +## Testing + +- **Temporary Directories (`TEST_TMPDIR`):** + - Tests in this repository only run in Bazel. **ALWAYS** use `process.env['TEST_TMPDIR']` and assert that it is set: + ```ts + import assert from 'node:assert'; + import { mkdtemp } from 'node:fs/promises'; + import { join } from 'node:path'; + + describe('...', () => { + let tempRoot: string; + + beforeAll(async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + tempRoot = await mkdtemp(join(baseTmpDir, 'angular-cli-test-')); + }); + }); + ``` + - **NEVER** use or fallback to `os.tmpdir()`. Bazel executes tests in hermetic sandboxes and sets `TEST_TMPDIR` to an isolated, sandboxed directory. Using `os.tmpdir()` can cause sandboxing failures, permission errors, or file leakage outside the Bazel sandbox. +- **Unit & Integration Tests:** + - Run all unit and integration tests: `pnpm bazel test //packages/...` + - Run a specific unit test target (e.g., `pnpm bazel test //packages/angular/build:test`). Note that package `:test` targets may exclude builder integration tests (such as specs under `**/builders/**/tests/**`). + - Run a specific builder integration test target (e.g., `pnpm bazel test //packages/angular/build:library_integration_tests` or `pnpm bazel test //packages/angular/build:application_integration_tests`). + - Query all test targets: `pnpm bazel query "tests(//packages/...)"` + - Find the exact test target for a specific spec file (using its workspace-relative path, which Bazel resolves as a source file target): `pnpm bazel query "kind(jasmine_test, rdeps(//packages/..., ))"` + - Focus specific tests when debugging: use `fdescribe()` and `fit()`. NEVER commit focused tests to the repository. + - Run tests without sharding when isolating or debugging: use `--config=no-sharding` with a specific test target (e.g., `pnpm bazel test //packages/angular/build:library_integration_tests --config=no-sharding`). + This disables test sharding (`--test_sharding_strategy=disabled`) and flaky test retries (`--flaky_test_attempts=1`). + This is especially useful when isolating test runs or debugging with focused tests (`fit`/`fdescribe`) to avoid empty shard failures and unnecessary re-runs. + Do not use this flag when running broad test suites (such as `//packages/...`), as executing tests without sharding takes significantly longer. +- **End-to-End Tests:** + - Run subset of E2E tests: `pnpm bazel test //tests:e2e_node22 --config=e2e --test_filter=""` + +## Pull Requests + +- Use the `gh` CLI (GitHub CLI) for creating and managing pull requests. +- **Fixup Commits:** + - When addressing review feedback, **ALWAYS** use fixup commits (`git commit --fixup `) instead of amending existing commits. This preserves commit history during review and allows reviewers to easily see incremental changes. + - Only use fixup commits for changes that directly belong to the target commit. Unrelated changes must be made in a separate commit with their own commit message, not as a fixup commit. + - Fixup commits are automatically squashed when merging with `pnpm ng-dev pr merge` or optionally when rebasing with `pnpm ng-dev pr rebase `. +- Use `pnpm ng-dev pr` commands: + - `pnpm ng-dev pr rebase `: Rebase a PR branch on its target branch and optionally squash fixup commits. + - `pnpm ng-dev pr merge `: Merge an approved PR into its targeted branches. diff --git a/BUILD.bazel b/BUILD.bazel new file mode 100644 index 000000000000..959b28996ec7 --- /dev/null +++ b/BUILD.bazel @@ -0,0 +1,111 @@ +load("@aspect_rules_ts//ts:defs.bzl", rules_js_tsconfig = "ts_config") +load("@bazel_skylib//rules:common_settings.bzl", "bool_flag") +load("@devinfra//bazel/validation:defs.bzl", "validate_ts_version_matching") +load("@npm//:defs.bzl", "npm_link_all_packages") +load("//tools:defaults.bzl", "copy_to_bin") + +package(default_visibility = ["//visibility:public"]) + +licenses(["notice"]) + +exports_files([ + "LICENSE", + "tsconfig.json", + "tsconfig-test.json", + "tsconfig-build-ng.json", + "tsconfig-build.json", + "package.json", + "node_modules/@angular/core/resources/best-practices.md", +]) + +npm_link_all_packages() + +rules_js_tsconfig( + name = "tsconfig", + src = "tsconfig.json", + visibility = [ + "//:__pkg__", + ], +) + +rules_js_tsconfig( + name = "build-tsconfig", + src = "tsconfig-build.json", + deps = [ + ":tsconfig", + "//:node_modules/@types/node", + ], +) + +rules_js_tsconfig( + name = "test-tsconfig", + src = "tsconfig-test.json", + deps = [ + ":tsconfig", + "//:node_modules/@types/jasmine", + "//:node_modules/@types/node", + ], +) + +rules_js_tsconfig( + name = "build-tsconfig-esm", + src = "tsconfig-build-esm.json", + deps = [ + ":tsconfig", + ], +) + +rules_js_tsconfig( + name = "test-tsconfig-esm", + src = "tsconfig-test-esm.json", + deps = [ + ":build-tsconfig-esm", + "//:node_modules/@types/jasmine", + "//:node_modules/@types/node", + ], +) + +# Files required by e2e tests +copy_to_bin( + name = "config-files", + srcs = [ + "package.json", + ], +) + +# Detect if the build is running under --stamp +config_setting( + name = "stamp", + values = {"stamp": "true"}, +) + +# If set will replace dependency versions with tarballs for packages in this repo +bool_flag( + name = "enable_package_json_tar_deps", + build_setting_default = False, +) + +config_setting( + name = "package_json_use_tar_deps", + flag_values = { + ":enable_package_json_tar_deps": "true", + }, +) + +# If set will replace dependency versions with snapshot repos for packages in this repo +bool_flag( + name = "enable_snapshot_repo_deps", + build_setting_default = False, +) + +config_setting( + name = "package_json_use_snapshot_repo_deps", + flag_values = { + ":enable_snapshot_repo_deps": "true", + }, +) + +validate_ts_version_matching( + module_bazel = "MODULE.bazel", + package_json = "package.json", +) diff --git a/CHANGELOG.md b/CHANGELOG.md index 307b7a129f8c..e72e912e9ad2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,576 +1,19408 @@ - -# [1.0.0-beta.24](https://github.com/angular/angular-cli/compare/v1.0.0-beta.23...v1.0.0-beta.24) (2016-12-20) + +# 22.3.0-next.4 (2026-10-28) -### Bug Fixes +### @angular/cli -* **@ngtools/webpack:** report errors during codegen ([#3608](https://github.com/angular/angular-cli/issues/3608)) ([0f604ac](https://github.com/angular/angular-cli/commit/0f604ac)) -* **build:** hashes in prod builds now changes when ID change. ([#3609](https://github.com/angular/angular-cli/issues/3609)) ([8e9abf9](https://github.com/angular/angular-cli/commit/8e9abf9)) -* **test:** exclude non spec files from test.ts ([#3538](https://github.com/angular/angular-cli/issues/3538)) ([bcb324f](https://github.com/angular/angular-cli/commit/bcb324f)) -* **tests:** serve `assets` files from `ng test` ([#3628](https://github.com/angular/angular-cli/issues/3628)) ([3459300](https://github.com/angular/angular-cli/commit/3459300)) -* **webpack:** correctly load component stylesheets ([#3511](https://github.com/angular/angular-cli/issues/3511)) ([d4da7bd](https://github.com/angular/angular-cli/commit/d4da7bd)) +| 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 -### Features +| 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 | -* **generate:** Show files updated when generating ([#3642](https://github.com/angular/angular-cli/issues/3642)) ([c011b04](https://github.com/angular/angular-cli/commit/c011b04)), closes [#3624](https://github.com/angular/angular-cli/issues/3624) -* **version:** display versions of [@angular](https://github.com/angular)/* and [@ngtools](https://github.com/ngtools)/* ([#3592](https://github.com/angular/angular-cli/issues/3592)) ([123f74d](https://github.com/angular/angular-cli/commit/123f74d)), closes [#3589](https://github.com/angular/angular-cli/issues/3589) +### @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 - -# [1.0.0-beta.23](https://github.com/angular/angular-cli/compare/v1.0.0-beta.22-1...v1.0.0-beta.23) (2016-12-15) +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------- | +| [c44762fd44](https://github.com/angular/angular-cli/commit/c44762fd4434c63723aae0e46a023cd7b057ac4c) | fix | only invoke smart default provider when value is undefined | -This beta was abandoned and unpublished due to a breaking bug. +### @angular-devkit/schematics -### Bug Fixes +| 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)) | -* **@ngtools/webpack:** keep the decorators in. ([#3583](https://github.com/angular/angular-cli/issues/3583)) ([db25183](https://github.com/angular/angular-cli/commit/db25183)) -* **@ngtools/webpack:** use tsconfig declaration flag to report decl errors ([#3499](https://github.com/angular/angular-cli/issues/3499)) ([c46de15](https://github.com/angular/angular-cli/commit/c46de15)) -* **blueprints:** remove app root barrel ([#3530](https://github.com/angular/angular-cli/issues/3530)) ([3329d46](https://github.com/angular/angular-cli/commit/3329d46)), closes [#3369](https://github.com/angular/angular-cli/issues/3369) -* **build:** added autoprefixer to prod ([1648d51](https://github.com/angular/angular-cli/commit/1648d51)), closes [#3156](https://github.com/angular/angular-cli/issues/3156) [#3164](https://github.com/angular/angular-cli/issues/3164) -* **build:** pin [@types](https://github.com/types)/lodash ([#3465](https://github.com/angular/angular-cli/issues/3465)) ([9b65481](https://github.com/angular/angular-cli/commit/9b65481)) -* **completion:** Update with the new help command ([#3479](https://github.com/angular/angular-cli/issues/3479)) ([0b5dc74](https://github.com/angular/angular-cli/commit/0b5dc74)) -* **dependencies:** reduce the dependencies further. ([#3488](https://github.com/angular/angular-cli/issues/3488)) ([901a64f](https://github.com/angular/angular-cli/commit/901a64f)) -* **deploy:** gh-pages checkout initial branch on error ([#3378](https://github.com/angular/angular-cli/issues/3378)) ([c5cd095](https://github.com/angular/angular-cli/commit/c5cd095)), closes [#3030](https://github.com/angular/angular-cli/issues/3030) [#2663](https://github.com/angular/angular-cli/issues/2663) [#1259](https://github.com/angular/angular-cli/issues/1259) -* **deploy:** gh-pages deploy fail after repo create ([#3386](https://github.com/angular/angular-cli/issues/3386)) ([0a68cc5](https://github.com/angular/angular-cli/commit/0a68cc5)), closes [#3385](https://github.com/angular/angular-cli/issues/3385) -* **gitignore:** No longer ignore VSCode settings ([#3477](https://github.com/angular/angular-cli/issues/3477)) ([8d88446](https://github.com/angular/angular-cli/commit/8d88446)) -* **help:** fix `ng help ` ([#3442](https://github.com/angular/angular-cli/issues/3442)) ([51659b9](https://github.com/angular/angular-cli/commit/51659b9)) -* **new:** Make sure the project name is valid. ([#3478](https://github.com/angular/angular-cli/issues/3478)) ([e836f92](https://github.com/angular/angular-cli/commit/e836f92)) -* **webpack:** fix some problems with errors not reported. ([#3444](https://github.com/angular/angular-cli/issues/3444)) ([09f9aa9](https://github.com/angular/angular-cli/commit/09f9aa9)) -* **webpack:** remove usage of __dirname from the config. ([#3422](https://github.com/angular/angular-cli/issues/3422)) ([8597786](https://github.com/angular/angular-cli/commit/8597786)) +### @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 | -### Features +### @angular/ssr -* Make CLI available without install ([761e86f](https://github.com/angular/angular-cli/commit/761e86f)), closes [#3126](https://github.com/angular/angular-cli/issues/3126) -* **build:** add lazy styles/scripts ([#3402](https://github.com/angular/angular-cli/issues/3402)) ([20bb864](https://github.com/angular/angular-cli/commit/20bb864)), closes [#3401](https://github.com/angular/angular-cli/issues/3401) [#3400](https://github.com/angular/angular-cli/issues/3400) -* **deps:** Unblock the version of Angular to >= 2.3 ([#3569](https://github.com/angular/angular-cli/issues/3569)) ([bd03100](https://github.com/angular/angular-cli/commit/bd03100)) -* **generate:** change generate --prefix option type from Boolean to string ([#3457](https://github.com/angular/angular-cli/issues/3457)) ([8d5a915](https://github.com/angular/angular-cli/commit/8d5a915)) -* **i18n:** add i18n command line options ([#3098](https://github.com/angular/angular-cli/issues/3098)) ([2a0a42d](https://github.com/angular/angular-cli/commit/2a0a42d)) -* **module:** component optional when generating module ([#3389](https://github.com/angular/angular-cli/issues/3389)) ([2fb2d13](https://github.com/angular/angular-cli/commit/2fb2d13)) -* **serve:** Add support to open with ssl. ([#3432](https://github.com/angular/angular-cli/issues/3432)) ([83dfc96](https://github.com/angular/angular-cli/commit/83dfc96)) +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------- | +| [194b64137e](https://github.com/angular/angular-cli/commit/194b64137e3830cd3ca0b2236c2a44b2873e2dec) | fix | preserve trailing slashes in query and fragment of redirect URLs | + -### Performance Improvements + -* **install time:** Remove dependency to zopfli. ([#3414](https://github.com/angular/angular-cli/issues/3414)) ([e6364a9](https://github.com/angular/angular-cli/commit/e6364a9)) +# 22.2.5 (2026-10-28) +### @angular/cli -### BREAKING CHANGES +| 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 | -* blueprints: The app root module and component must now be imported directly. (e.g., use `import { AppModule } from './app/app.module';` instead of `import { AppModule } from './app/';`) +### @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 - -# [1.0.0-beta.22-1](https://github.com/angular/angular-cli/compare/v1.0.0-beta.22...v1.0.0-beta.22-1) (2016-12-05) +| 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 -### Bug Fixes +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------- | +| [ad04d689bb](https://github.com/angular/angular-cli/commit/ad04d689bbcc2778d3a0a006dcf3224da2e08536) | fix | only invoke smart default provider when value is undefined | -* **@ngtools/webpack:** performance improvement. ([#3360](https://github.com/angular/angular-cli/issues/3360)) ([4dcfe27](https://github.com/angular/angular-cli/commit/4dcfe27)) -* **deploy:** clean up gh-pages obsolete files ([#3081](https://github.com/angular/angular-cli/issues/3081)) ([#3333](https://github.com/angular/angular-cli/issues/3333)) ([51869fb](https://github.com/angular/angular-cli/commit/51869fb)) -* change apiFilter querystring to query in ng doc([#3383](https://github.com/angular/angular-cli/issues/3383)) ([5b2a0fb](https://github.com/angular/angular-cli/commit/5b2a0fb)), closes [#3363](https://github.com/angular/angular-cli/issues/3363) +### @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 - -# [1.0.0-beta.22](https://github.com/angular/angular-cli/compare/v1.0.0-beta.21...v1.0.0-beta.22) (2016-12-02) +| 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 -### Bug Fixes +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------- | +| [7b9c8d0b5d](https://github.com/angular/angular-cli/commit/7b9c8d0b5d39af7134191d2e2bfd717d375a1eac) | fix | preserve trailing slashes in query and fragment of redirect URLs | -* **@ngtools/webpack:** fixed path resolution for entry modules and lazy routes ([#3332](https://github.com/angular/angular-cli/issues/3332)) ([45d5154](https://github.com/angular/angular-cli/commit/45d5154)) -* **build:** don't inline sourcemaps ([#3262](https://github.com/angular/angular-cli/issues/3262)) ([859d905](https://github.com/angular/angular-cli/commit/859d905)) -* **build:** use custom index value when copying to 404.html during github deploy ([#3201](https://github.com/angular/angular-cli/issues/3201)) ([b1cbf17](https://github.com/angular/angular-cli/commit/b1cbf17)) -* **ngtools/webpack:** move the generate directory to a separate dir ([#3256](https://github.com/angular/angular-cli/issues/3256)) ([d1037df](https://github.com/angular/angular-cli/commit/d1037df)) -* **version:** bump ast-tools and webpack versions to correct mismatch with published packages ([54ef738](https://github.com/angular/angular-cli/commit/54ef738)) + + -### Features +# 21.2.29 (2026-10-28) -* **angular:** Update Angular2 version to 2.2.3 ([#3295](https://github.com/angular/angular-cli/issues/3295)) ([ed305a2](https://github.com/angular/angular-cli/commit/ed305a2)) -* **build:** add --verbose and --progress flags ([#2858](https://github.com/angular/angular-cli/issues/2858)) ([f6f24e7](https://github.com/angular/angular-cli/commit/f6f24e7)), closes [#1836](https://github.com/angular/angular-cli/issues/1836) [#2012](https://github.com/angular/angular-cli/issues/2012) -* **build:** auto generate vendor chunk ([#3117](https://github.com/angular/angular-cli/issues/3117)) ([bf9c8f1](https://github.com/angular/angular-cli/commit/bf9c8f1)) -* **new:** include routing in spec and inline template when called with `--routing` ([#3252](https://github.com/angular/angular-cli/issues/3252)) ([53ab4df](https://github.com/angular/angular-cli/commit/53ab4df)) -* **serve:** add --hmr flag for HotModuleReplacement support ([#3330](https://github.com/angular/angular-cli/issues/3330)) ([46efa9e](https://github.com/angular/angular-cli/commit/46efa9e)) +### @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` | -### BREAKING CHANGES +### @angular/build -* build: `ng build/serve` now generates `vendor.bundle.js` by -default. +| 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 | + + - -# [1.0.0-beta.21](https://github.com/angular/angular-cli/compare/v1.0.0-beta.20-1...v1.0.0-beta.21) (2016-11-23) +# 20.3.42 (2026-10-28) +### @angular-devkit/build-angular -### Bug Fixes +| 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-cli:** add necessary dependencies. ([#3152](https://github.com/angular/angular-cli/issues/3152)) ([8f574e4](https://github.com/angular/angular-cli/commit/8f574e4)), closes [#3148](https://github.com/angular/angular-cli/issues/3148) -* **angular-cli:** add necessary dependency. ([f7704b0](https://github.com/angular/angular-cli/commit/f7704b0)) -* **angular-cli:** change version of webpack plugin. ([07e96ea](https://github.com/angular/angular-cli/commit/07e96ea)) -* **aot:** lock the angular version to 2.2.1. ([#3242](https://github.com/angular/angular-cli/issues/3242)) ([6e8a848](https://github.com/angular/angular-cli/commit/6e8a848)) -* **editorconfig:** use off instead of 0 for max line length ([#3186](https://github.com/angular/angular-cli/issues/3186)) ([f833d25](https://github.com/angular/angular-cli/commit/f833d25)) -* **generate:** revert change to component dir in generate module, as it caused component declaration to go to parent module ([#3158](https://github.com/angular/angular-cli/issues/3158)) ([71bf855](https://github.com/angular/angular-cli/commit/71bf855)) -* **github-pages-deploy:** Show more accurate url ([#3160](https://github.com/angular/angular-cli/issues/3160)) ([a431389](https://github.com/angular/angular-cli/commit/a431389)) +### @angular/build +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------------ | +| [223891de4c](https://github.com/angular/angular-cli/commit/223891de4c43315fe0d863e1ffacf7395a30abc7) | fix | escape prerender redirect URLs | -### Features + -* **build:** add sourcemap option ([#3113](https://github.com/angular/angular-cli/issues/3113)) ([6f9d2c1](https://github.com/angular/angular-cli/commit/6f9d2c1)) + +# 22.3.0-next.3 (2026-10-22) +### @angular/cli - -# [1.0.0-beta.20](https://github.com/angular/angular-cli/compare/v1.0.0-beta.19...v1.0.0-beta.20-1) (2016-11-16) +| 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 -### Bug Fixes +| 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 | -* **@ngtools/webpack:** fixed relative path for AoT. ([#3114](https://github.com/angular/angular-cli/issues/3114)) ([27a034d](https://github.com/angular/angular-cli/commit/27a034d)) -* **aot:** exclude spec files from aot ([#2758](https://github.com/angular/angular-cli/issues/2758)) ([215e555](https://github.com/angular/angular-cli/commit/215e555)) -* **aot:** output the sources in the sourcemap. ([#3107](https://github.com/angular/angular-cli/issues/3107)) ([7127dba](https://github.com/angular/angular-cli/commit/7127dba)) -* **aot:** remove the genDir plugin option. ([0e91dfe](https://github.com/angular/angular-cli/commit/0e91dfe)), closes [#2849](https://github.com/angular/angular-cli/issues/2849) [#2876](https://github.com/angular/angular-cli/issues/2876) -* **aot:** Use the proper path when statically analyzing lazy routes. ([#2992](https://github.com/angular/angular-cli/issues/2992)) ([88131a0](https://github.com/angular/angular-cli/commit/88131a0)), closes [#2452](https://github.com/angular/angular-cli/issues/2452) [#2735](https://github.com/angular/angular-cli/issues/2735) [#2900](https://github.com/angular/angular-cli/issues/2900) -* **build:** correct forkChecker option for ATS. ([#3011](https://github.com/angular/angular-cli/issues/3011)) ([a987cf5](https://github.com/angular/angular-cli/commit/a987cf5)) -* **build:** enable chunkhash in inline.js ([30cc482](https://github.com/angular/angular-cli/commit/30cc482)), closes [#2899](https://github.com/angular/angular-cli/issues/2899) -* **build:** show full error stats ([#2879](https://github.com/angular/angular-cli/issues/2879)) ([d59fa1f](https://github.com/angular/angular-cli/commit/d59fa1f)) -* **deps:** explicitely add portfinder ([#2831](https://github.com/angular/angular-cli/issues/2831)) ([2d8f162](https://github.com/angular/angular-cli/commit/2d8f162)), closes [#2755](https://github.com/angular/angular-cli/issues/2755) [#2769](https://github.com/angular/angular-cli/issues/2769) -* **e2e:** fix broken test pipeline ([#2999](https://github.com/angular/angular-cli/issues/2999)) ([37a1225](https://github.com/angular/angular-cli/commit/37a1225)) -* **generate:** fix module component path if module is created in child folder ([#3066](https://github.com/angular/angular-cli/issues/3066)) ([38d5f2c](https://github.com/angular/angular-cli/commit/38d5f2c)), closes [#3063](https://github.com/angular/angular-cli/issues/3063) -* **generate:** stop default browser error from ng new --routing ([a45a1f2](https://github.com/angular/angular-cli/commit/a45a1f2)), closes [#2794](https://github.com/angular/angular-cli/issues/2794) -* **package:** add some more metadata to webpack package.json ([c2dbf88](https://github.com/angular/angular-cli/commit/c2dbf88)), closes [#2854](https://github.com/angular/angular-cli/issues/2854) -* **serve:** added accept html headers option to webpack-dev-server ([#2990](https://github.com/angular/angular-cli/issues/2990)) ([86f2a1b](https://github.com/angular/angular-cli/commit/86f2a1b)), closes [#2989](https://github.com/angular/angular-cli/issues/2989) -* **test:** catches module loading errors ([f09439c](https://github.com/angular/angular-cli/commit/f09439c)), closes [#2640](https://github.com/angular/angular-cli/issues/2640) [#2785](https://github.com/angular/angular-cli/issues/2785) -* **version:** update version of [@angular](https://github.com/angular) packages. ([#3145](https://github.com/angular/angular-cli/issues/3145)) ([a2f0a1a](https://github.com/angular/angular-cli/commit/a2f0a1a)) -* bypass Watchman check ([#2846](https://github.com/angular/angular-cli/issues/2846)) ([9aa1099](https://github.com/angular/angular-cli/commit/9aa1099)), closes [#2791](https://github.com/angular/angular-cli/issues/2791) +### @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 | -### Features +### @angular/ssr -* **build:** add loaders for fonts ([3497373](https://github.com/angular/angular-cli/commit/3497373)), closes [#1765](https://github.com/angular/angular-cli/issues/1765) -* **build:** use appConfig.index to set output index file ([d3fd8b0](https://github.com/angular/angular-cli/commit/d3fd8b0)), closes [#2241](https://github.com/angular/angular-cli/issues/2241) [#2767](https://github.com/angular/angular-cli/issues/2767) -* **build:** use static files for css ([a6415cc](https://github.com/angular/angular-cli/commit/a6415cc)), closes [#2148](https://github.com/angular/angular-cli/issues/2148) [#2020](https://github.com/angular/angular-cli/issues/2020) [#2826](https://github.com/angular/angular-cli/issues/2826) [#2646](https://github.com/angular/angular-cli/issues/2646) -* **serve:** allow CORS access while running ng serve ([#2872](https://github.com/angular/angular-cli/issues/2872)) ([#3009](https://github.com/angular/angular-cli/issues/3009)) ([7c834a8](https://github.com/angular/angular-cli/commit/7c834a8)) +| 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 | + -### BREAKING CHANGES + -* aot: Using relative paths might lead to path clashing. We -now properly output an error in this case. +# 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 | - -# [1.0.0-beta.19](https://github.com/angular/angular-cli/compare/v1.0.0-beta.18...v1.0.0-beta.19) (2016-10-28) +### @angular-devkit/build-angular +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [651d1d293d](https://github.com/angular/angular-cli/commit/651d1d293dd47454d76f2f7ddbc70f7a5607366b) | fix | update webpack to 5.112.0 | -### Bug Fixes +### @angular/build -* **appveyor:** add workaround for webdriver bug ([#2862](https://github.com/angular/angular-cli/issues/2862)) ([2b17b46](https://github.com/angular/angular-cli/commit/2b17b46)) -* **compiler:** update codegen API ([#2919](https://github.com/angular/angular-cli/issues/2919)) ([b50c121](https://github.com/angular/angular-cli/commit/b50c121)), closes [#2917](https://github.com/angular/angular-cli/issues/2917) -* **e2e:** fix travis e2e ([#2914](https://github.com/angular/angular-cli/issues/2914)) ([b62b996](https://github.com/angular/angular-cli/commit/b62b996)) +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------ | +| [a081d8b6db](https://github.com/angular/angular-cli/commit/a081d8b6dbef26d33cb22b4b40c9a843a2b87047) | fix | bump vite to 7.3.7 | + -### Features + -* **test:** make code coverage and lint optional ([#2840](https://github.com/angular/angular-cli/issues/2840)) ([8944d04](https://github.com/angular/angular-cli/commit/8944d04)), closes [#1799](https://github.com/angular/angular-cli/issues/1799) +# 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 | - -# [1.0.0-beta.18](https://github.com/angular/angular-cli/compare/v1.0.0-beta.17...v1.0.0-beta.18) (2016-10-20) +### @angular-devkit/build-angular +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [f905af981b](https://github.com/angular/angular-cli/commit/f905af981bb1f812944dfa31a623b7fcbb08f8e9) | fix | update webpack to 5.112.0 | -### Bug Fixes +### @angular/build -* **#1875:** Support npm linked libraries ([#2291](https://github.com/angular/angular-cli/issues/2291)) ([8bf69d9](https://github.com/angular/angular-cli/commit/8bf69d9)) -* **aot-tools:** add missing tsc-wrapped dep ([1587c1b](https://github.com/angular/angular-cli/commit/1587c1b)), closes [#2498](https://github.com/angular/angular-cli/issues/2498) [#2598](https://github.com/angular/angular-cli/issues/2598) -* **build:** add react minification support ([e23e0fe](https://github.com/angular/angular-cli/commit/e23e0fe)), closes [#2110](https://github.com/angular/angular-cli/issues/2110) [#2754](https://github.com/angular/angular-cli/issues/2754) -* **build:** fix sourcemap in prod ([d292eac](https://github.com/angular/angular-cli/commit/d292eac)), closes [#2533](https://github.com/angular/angular-cli/issues/2533) [#2519](https://github.com/angular/angular-cli/issues/2519) -* **build:** set tls and net node builtins to empty ([7424795](https://github.com/angular/angular-cli/commit/7424795)), closes [#1696](https://github.com/angular/angular-cli/issues/1696) [#2626](https://github.com/angular/angular-cli/issues/2626) -* **build:** use outputPath from config ([ec0cdb5](https://github.com/angular/angular-cli/commit/ec0cdb5)), closes [#2511](https://github.com/angular/angular-cli/issues/2511) [#2611](https://github.com/angular/angular-cli/issues/2611) -* **doc:** update invalid link ([e17d4a8](https://github.com/angular/angular-cli/commit/e17d4a8)), closes [#2553](https://github.com/angular/angular-cli/issues/2553) -* **docs:** Correct the usage of redirecting the output from `ng completion`. ([2225027](https://github.com/angular/angular-cli/commit/2225027)), closes [#2635](https://github.com/angular/angular-cli/issues/2635) -* **generate:** show error when no name is specified ([249ccf7](https://github.com/angular/angular-cli/commit/249ccf7)), closes [#2684](https://github.com/angular/angular-cli/issues/2684) -* **init:** ignore favicon ([699ebba](https://github.com/angular/angular-cli/commit/699ebba)), closes [#2274](https://github.com/angular/angular-cli/issues/2274) [#2617](https://github.com/angular/angular-cli/issues/2617) -* override ui write level ([4608445](https://github.com/angular/angular-cli/commit/4608445)), closes [#2540](https://github.com/angular/angular-cli/issues/2540) [#2627](https://github.com/angular/angular-cli/issues/2627) -* **init:** throw when called with mobile flag ([#2753](https://github.com/angular/angular-cli/issues/2753)) ([9b1c3e0](https://github.com/angular/angular-cli/commit/9b1c3e0)), closes [#2679](https://github.com/angular/angular-cli/issues/2679) -* **karma:** Add cli config poll option to karma default config ([#2486](https://github.com/angular/angular-cli/issues/2486)) ([63023ae](https://github.com/angular/angular-cli/commit/63023ae)) -* **new:** add prefix to spec name ([1307dc8](https://github.com/angular/angular-cli/commit/1307dc8)), closes [/github.com/angular/angular-cli/commit/06976f4f07a6d6065124a819b95634bddaac4598#commitcomment-19241601](https://github.com//github.com/angular/angular-cli/commit/06976f4f07a6d6065124a819b95634bddaac4598/issues/commitcomment-19241601) [#2595](https://github.com/angular/angular-cli/issues/2595) -* **new:** fix relativeRootPath for typeRoots ([eb2f939](https://github.com/angular/angular-cli/commit/eb2f939)), closes [#2206](https://github.com/angular/angular-cli/issues/2206) [#2597](https://github.com/angular/angular-cli/issues/2597) -* **serve:** enable routes with dots ([#2535](https://github.com/angular/angular-cli/issues/2535)) ([6f8b1b5](https://github.com/angular/angular-cli/commit/6f8b1b5)), closes [#2168](https://github.com/angular/angular-cli/issues/2168) -* **set:** output value for additional props ([f7bf0aa](https://github.com/angular/angular-cli/commit/f7bf0aa)), closes [#1900](https://github.com/angular/angular-cli/issues/1900) [#2614](https://github.com/angular/angular-cli/issues/2614) +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------ | +| [a1e3016f1e](https://github.com/angular/angular-cli/commit/a1e3016f1efe65b49b9d6affd13e6fe1d95607e4) | fix | bump vite to 7.3.7 | + -### Features + -* **build:** add gzip to serve --prod ([7c13cc5](https://github.com/angular/angular-cli/commit/7c13cc5)), closes [#2028](https://github.com/angular/angular-cli/issues/2028) [#2621](https://github.com/angular/angular-cli/issues/2621) -* **build:** add support for assets array ([#2570](https://github.com/angular/angular-cli/issues/2570)) ([de3c275](https://github.com/angular/angular-cli/commit/de3c275)) -* **build:** added postcss-discard-comments ([883fe46](https://github.com/angular/angular-cli/commit/883fe46)), closes [#2593](https://github.com/angular/angular-cli/issues/2593) -* **generate:** specify class type via dot notation ([#2707](https://github.com/angular/angular-cli/issues/2707)) ([c2dd94c](https://github.com/angular/angular-cli/commit/c2dd94c)), closes [#2155](https://github.com/angular/angular-cli/issues/2155) -* **serve:** implement open browser option ([8bddabe](https://github.com/angular/angular-cli/commit/8bddabe)), closes [#1081](https://github.com/angular/angular-cli/issues/1081) [#2489](https://github.com/angular/angular-cli/issues/2489) -* **ssl:** add support for the ssl options of the ng serve task: --ssl, --ssl-cert, and --ssl-key ([#2792](https://github.com/angular/angular-cli/issues/2792)) ([ba414ab](https://github.com/angular/angular-cli/commit/ba414ab)) +# 22.2.4 (2026-10-22) +### @angular/cli -### BREAKING CHANGES +| 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 | -* generate: The ability to specify a class type via an additional arg has been replaced by combining the name and type args separated by a dot +### @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 - -# [1.0.0-beta.17](https://github.com/angular/angular-cli/compare/v1.0.0-beta.16...v1.0.0-beta.17) (2016-10-07) +| 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 -### Bug Fixes +| 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 | -* **build:** remove html-loader ([#2537](https://github.com/angular/angular-cli/issues/2537)) ([afb36e8](https://github.com/angular/angular-cli/commit/afb36e8)) -* **build:** use baseUrl and paths from tsconfig ([#2470](https://github.com/angular/angular-cli/issues/2470)) ([32e60b7](https://github.com/angular/angular-cli/commit/32e60b7)) -* **cli:** fix `completion` and `make-this-awesome` command invalid problem([#1889](https://github.com/angular/angular-cli/issues/1889)) ([4b36ecf](https://github.com/angular/angular-cli/commit/4b36ecf)), closes [#1890](https://github.com/angular/angular-cli/issues/1890) -* **generate:** use prefix when initializing app ([#2046](https://github.com/angular/angular-cli/issues/2046)) ([#2367](https://github.com/angular/angular-cli/issues/2367)) ([06976f4](https://github.com/angular/angular-cli/commit/06976f4)) -* **typo:** fixed typo in README ([#2383](https://github.com/angular/angular-cli/issues/2383)) ([f6a39b2](https://github.com/angular/angular-cli/commit/f6a39b2)) -* **webpack:** Added ContextReplacementPlugin to remove ng test warning ([c2f4b37](https://github.com/angular/angular-cli/commit/c2f4b37)), closes [#2362](https://github.com/angular/angular-cli/issues/2362) -* check for old version of the CLI on empty project ([2b6bfe7](https://github.com/angular/angular-cli/commit/2b6bfe7)), closes [#2135](https://github.com/angular/angular-cli/issues/2135) [#2178](https://github.com/angular/angular-cli/issues/2178) + + -### Features +# 22.3.0-next.2 (2026-10-15) -* **aot:** adding README and type checking. ([8a5b265](https://github.com/angular/angular-cli/commit/8a5b265)), closes [#2527](https://github.com/angular/angular-cli/issues/2527) -* **aot:** creating files in a virtual fs. ([#2464](https://github.com/angular/angular-cli/issues/2464)) ([790a1b4](https://github.com/angular/angular-cli/commit/790a1b4)) -* **aot:** do not populate the whole filesystem if nothing changed ([#2490](https://github.com/angular/angular-cli/issues/2490)) ([b5771df](https://github.com/angular/angular-cli/commit/b5771df)) -* **template:** issue template look better ([#2384](https://github.com/angular/angular-cli/issues/2384)) ([398cfb3](https://github.com/angular/angular-cli/commit/398cfb3)) +### @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 - -# [1.0.0-beta.16](https://github.com/angular/angular-cli/compare/v1.0.0-beta.15...v1.0.0-beta.16) (2016-09-28) +| 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 -### Bug Fixes +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------- | +| [b39a5fcda](https://github.com/angular/angular-cli/commit/b39a5fcdac288e317f3b00468c09173a1d306f98) | fix | avoid stack overflow when chaining many rules | -* **build:** fail ng build on error ([#2360](https://github.com/angular/angular-cli/issues/2360)) ([aa48c30](https://github.com/angular/angular-cli/commit/aa48c30)), closes [#2014](https://github.com/angular/angular-cli/issues/2014) -* **build:** use config output path as default ([#2158](https://github.com/angular/angular-cli/issues/2158)) ([49a120b](https://github.com/angular/angular-cli/commit/49a120b)) -* **generate:** Update directive.spec.ts blueprint to fix incorret import ([#1940](https://github.com/angular/angular-cli/issues/1940)) ([93da512](https://github.com/angular/angular-cli/commit/93da512)) -* **karma:** set defaults for karma.conf.js ([#1837](https://github.com/angular/angular-cli/issues/1837)) ([e2e94a5](https://github.com/angular/angular-cli/commit/e2e94a5)) -* **test:** correctly report packages spec failures ([#2238](https://github.com/angular/angular-cli/issues/2238)) ([3102453](https://github.com/angular/angular-cli/commit/3102453)) +### @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 | -### Features +### @angular/ssr -* **webpackDevServer:** Add watchOptions for webpackDevServer ([#1814](https://github.com/angular/angular-cli/issues/1814)) ([ce03088](https://github.com/angular/angular-cli/commit/ce03088)) +| 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 | + + - -# [1.0.0-beta.15](https://github.com/angular/angular-cli/compare/v1.0.0-beta.14...v1.0.0-beta.15) (2016-09-20) +# 22.2.3 (2026-10-15) +### @angular/cli -### Bug Fixes +| 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 | -* **help:** fix a bug where help was not available inside a project. ([#2146](https://github.com/angular/angular-cli/issues/2146)) ([5b880b2](https://github.com/angular/angular-cli/commit/5b880b2)) -* pin beta package versions ([#2236](https://github.com/angular/angular-cli/issues/2236)) ([638a54b](https://github.com/angular/angular-cli/commit/638a54b)), closes [#2234](https://github.com/angular/angular-cli/issues/2234) +### @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 - -# [1.0.0-beta.14](https://github.com/angular/angular-cli/compare/v1.0.0-beta.11-webpack.2...v1.0.0-beta.14) (2016-09-15) +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------- | +| [cf1f3b9d4](https://github.com/angular/angular-cli/commit/cf1f3b9d43177e15cf9a00973e6da3baeaf4b2dd) | fix | avoid stack overflow when chaining many rules | +### @angular/build -### Bug Fixes +| 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 | -* **config:** change css regex which causes error ([#2069](https://github.com/angular/angular-cli/issues/2069)) ([7096cc9](https://github.com/angular/angular-cli/commit/7096cc9)), closes [angular/angular#11445](https://github.com/angular/angular/issues/11445) -* **init:** fix link and npm install ([#2086](https://github.com/angular/angular-cli/issues/2086)) ([7a39162](https://github.com/angular/angular-cli/commit/7a39162)) +### @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 | + - -# [1.0.0-beta.11-webpack.9](https://github.com/angular/angular-cli/compare/v1.0.0-beta.11-webpack.2...v1.0.0-beta.11-webpack.9) (2016-09-13) + +# 21.2.27 (2026-10-14) -### Bug Fixes +### @angular/cli -* add ast-tools as a direct dependency to angular-cli ([bf18f4d](https://github.com/angular/angular-cli/commit/bf18f4d)) -* **bootstrap:** fix windows node_modules path ([#2037](https://github.com/angular/angular-cli/issues/2037)) ([c41600a](https://github.com/angular/angular-cli/commit/c41600a)) -* **find-lazy-modules:** Allow for any valid keys/value to be used ([#1987](https://github.com/angular/angular-cli/issues/1987)) ([caa3142](https://github.com/angular/angular-cli/commit/caa3142)), closes [#1891](https://github.com/angular/angular-cli/issues/1891) [#1960](https://github.com/angular/angular-cli/issues/1960) -* **lint:** Updated the main.ts's blueprint to fix the ng lint failure ([#1903](https://github.com/angular/angular-cli/issues/1903)) ([#1904](https://github.com/angular/angular-cli/issues/1904)) ([0d9d646](https://github.com/angular/angular-cli/commit/0d9d646)) -* add loader for gif ([#2066](https://github.com/angular/angular-cli/issues/2066)) ([126c82b](https://github.com/angular/angular-cli/commit/126c82b)), closes [#1878](https://github.com/angular/angular-cli/issues/1878) -* lazy loading now works as expected with latest webpack ([#2038](https://github.com/angular/angular-cli/issues/2038)) ([33c9c73](https://github.com/angular/angular-cli/commit/33c9c73)) -* **test:** add build environment to karma ([#2074](https://github.com/angular/angular-cli/issues/2074)) ([b6a2165](https://github.com/angular/angular-cli/commit/b6a2165)) +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------- | +| [2e1742adf](https://github.com/angular/angular-cli/commit/2e1742adf28645635bb6cff3cc08ad9c94c7d085) | fix | update zod to 4.6.6 | + -### Features + -* **module:** add ability to generate a routing file for new modules ([#1971](https://github.com/angular/angular-cli/issues/1971)) ([9ddba69](https://github.com/angular/angular-cli/commit/9ddba69)) -* **module:** select module to add generations to for declaration ([#1966](https://github.com/angular/angular-cli/issues/1966)) ([a647e51](https://github.com/angular/angular-cli/commit/a647e51)) +# 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 | - -# [1.0.0-beta.11-webpack.3](https://github.com/angular/angular-cli/compare/v1.0.0-beta.11-webpack.2...v1.0.0-beta.11-webpack.3) (2016-08-29) + + -### Bug Fixes +# 22.3.0-next.1 (2026-10-07) -* **lint:** change " to ' ([#1779](https://github.com/angular/angular-cli/issues/1779)) ([e572bb4](https://github.com/angular/angular-cli/commit/e572bb4)) -* change inline-source-map to source-map for dev and common, prod already supports ([#1659](https://github.com/angular/angular-cli/issues/1659)) ([e0454e3](https://github.com/angular/angular-cli/commit/e0454e3)) -* **test:** use updated ngModules in blueprint ([#1680](https://github.com/angular/angular-cli/issues/1680)) ([cb67d25](https://github.com/angular/angular-cli/commit/cb67d25)) -* denodeify is needed in prod now too ([#1879](https://github.com/angular/angular-cli/issues/1879)) ([5e68151](https://github.com/angular/angular-cli/commit/5e68151)) -* **build:** copy dot files in assets ([8c566ca](https://github.com/angular/angular-cli/commit/8c566ca)), closes [#1758](https://github.com/angular/angular-cli/issues/1758) [#1847](https://github.com/angular/angular-cli/issues/1847) -* fix compilation errors for the whole project ([#1864](https://github.com/angular/angular-cli/issues/1864)) ([8be7096](https://github.com/angular/angular-cli/commit/8be7096)) -* **config:** misnamed variable causing errors. ([e9ea554](https://github.com/angular/angular-cli/commit/e9ea554)) -* **generate:** use canonical paths for template and style URLs ([339af33](https://github.com/angular/angular-cli/commit/339af33)), closes [#1840](https://github.com/angular/angular-cli/issues/1840) -* **init:** karma paths reflect sourceDir config ([#1686](https://github.com/angular/angular-cli/issues/1686)) ([504a497](https://github.com/angular/angular-cli/commit/504a497)), closes [#1683](https://github.com/angular/angular-cli/issues/1683) -* **mobile:** add `icons/` path in front of icon `src` values ([cc5e9ad](https://github.com/angular/angular-cli/commit/cc5e9ad)), closes [#1179](https://github.com/angular/angular-cli/issues/1179) [#1181](https://github.com/angular/angular-cli/issues/1181) -* **mobile:** remove icon `density` field from manifest ([382487b](https://github.com/angular/angular-cli/commit/382487b)), closes [#1178](https://github.com/angular/angular-cli/issues/1178) [#1180](https://github.com/angular/angular-cli/issues/1180) -* **prod:** fix function name mangling ([9188ea2](https://github.com/angular/angular-cli/commit/9188ea2)), closes [#1644](https://github.com/angular/angular-cli/issues/1644) [#1644](https://github.com/angular/angular-cli/issues/1644) [#1662](https://github.com/angular/angular-cli/issues/1662) -* improve 'ember'->'ng' replacement ([80512ba](https://github.com/angular/angular-cli/commit/80512ba)), closes [#1405](https://github.com/angular/angular-cli/issues/1405) [#1829](https://github.com/angular/angular-cli/issues/1829) -* removed travis-specific configuration from karma ([#1815](https://github.com/angular/angular-cli/issues/1815)) ([f03f275](https://github.com/angular/angular-cli/commit/f03f275)) +### @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 | -### Features +### @angular/build -* add features in get-dependent-files.ts ([#1525](https://github.com/angular/angular-cli/issues/1525)) ([7565f2d](https://github.com/angular/angular-cli/commit/7565f2d)) -* Add LCOV reporting by default in karma remap instanbul reporter ([#1657](https://github.com/angular/angular-cli/issues/1657)) ([10dd465](https://github.com/angular/angular-cli/commit/10dd465)) -* **build:** implement --base-href argument ([74b29b3](https://github.com/angular/angular-cli/commit/74b29b3)), closes [#1064](https://github.com/angular/angular-cli/issues/1064) [#1506](https://github.com/angular/angular-cli/issues/1506) -* **build:** silence sourcemap warnings for vendors ([#1673](https://github.com/angular/angular-cli/issues/1673)) ([67098e0](https://github.com/angular/angular-cli/commit/67098e0)) -* **build:** update angular-cli.json ([#1633](https://github.com/angular/angular-cli/issues/1633)) ([3dcd49b](https://github.com/angular/angular-cli/commit/3dcd49b)) -* **feature:** add ability to generate feature modules ([#1867](https://github.com/angular/angular-cli/issues/1867)) ([1f4c6fe](https://github.com/angular/angular-cli/commit/1f4c6fe)) -* **module:** add generation of modules ([f40e6f1](https://github.com/angular/angular-cli/commit/f40e6f1)), closes [#1650](https://github.com/angular/angular-cli/issues/1650) -* **serve:** add proxy support ([9d69748](https://github.com/angular/angular-cli/commit/9d69748)) -* **tslint:** add validation for selector prefix ([9ff8c09](https://github.com/angular/angular-cli/commit/9ff8c09)), closes [#1565](https://github.com/angular/angular-cli/issues/1565) +| 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 | + + - -# [1.0.0-beta.11-webpack.2](https://github.com/angular/angular-cli/compare/v1.0.0-beta.10-webpack...v1.0.0-beta.11-webpack.2) (2016-08-10) +# 22.2.2 (2026-10-07) +### @angular-devkit/build-angular -### Bug Fixes +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [cfe970c8bc](https://github.com/angular/angular-cli/commit/cfe970c8bcfbc47e9fa1fa6b17e41cdf1d3b30bf) | fix | preserve Host header when proxying in ssr-dev-server | -* **webpack-copy:** copies files from public/ directory to dist/ and preserves references ([b11bc94](https://github.com/angular/angular-cli/commit/b11bc94)) -* Set fs building/polyfill empty for better package support ([#1599](https://github.com/angular/angular-cli/issues/1599)) ([560ae8f](https://github.com/angular/angular-cli/commit/560ae8f)) -* Updated webpack-karma which has proper peer deps settings ([#1597](https://github.com/angular/angular-cli/issues/1597)) ([ace720b](https://github.com/angular/angular-cli/commit/ace720b)) +### @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 | -### Features + -* add utility functions for route generation ([#1330](https://github.com/angular/angular-cli/issues/1330)) ([4fd8e9c](https://github.com/angular/angular-cli/commit/4fd8e9c)) -* ngmodules and insert components based on the AST ([#1616](https://github.com/angular/angular-cli/issues/1616)) ([5bcb7be](https://github.com/angular/angular-cli/commit/5bcb7be)) + +# 21.2.26 (2026-10-07) +### @angular/cli - -# [1.0.0-beta.11-webpack](https://github.com/angular/angular-cli/compare/v1.0.0-beta.10...v1.0.0-beta.11-webpack) (2016-08-02) +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------- | +| [9d2987f354](https://github.com/angular/angular-cli/commit/9d2987f354df7718ec76992cf28a0ab6cbf96539) | fix | bump @modelcontextprotocol/sdk to 1.31.0 | -Hey you! Yes, you! Angular-CLI team here. You know us, but we don't know you enough. And we like to hear about you too. That's why we did this release, so that you could check out for us, as we're looking out for you. + -Anyway, here goes... + -### Features +# 20.3.39 (2026-10-07) -🎺 **We moved the build system from SystemJS to Webpack.** 🎉 +### @angular/cli -🎊 Yeah! 🎊 \^_\^ +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------- | +| [373a7cedce](https://github.com/angular/angular-cli/commit/373a7cedce59662f6db6f5361192b6158377e01d) | fix | bump @modelcontextprotocol/sdk to 1.31.0 | -This is kind of a big deal, really. This will mean less thinking about the internals of the CLI and SystemJS, less time spent configuring a new npm package and karma, your life is going to be much easier! More coding where it actually matters, faster builds, more time spent with your loved ones, and lots of other goodies. Just for you. You'll love it! + -We want to make sure it's ready. That's why we need your help. Basically, things _should_ work. Build and Serve should work. Also, testing and E2E should too. To put it short, everything should work as it was before. But we're not certain! Test every commands you can think of. Use your normal work flows. We need you to test your projects and file issues about it. + -If you have a special build file that requires shuffling files around in Broccoli, give it a try without that. Note that TypeScript 2.0 path mapping is supported by the CLI so that might help you find out files. +# 21.2.25 (2026-10-05) -There's a migration document to move your project over. It's not complete yet, but we're working on it. Here's the PR: https://github.com/angular/angular-cli/pull/1456. The main take away is that most build configuration and system configuration should not be needed anymore. +### @angular/build -Please note that this is a really alpha release of this, and we want to tighten every nut and bolt before making it an official beta. +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | --------------------- | +| [7e6d2da084](https://github.com/angular/angular-cli/commit/7e6d2da08406f240b18b1d7e692cad27f29af314) | fix | bump piscina to 5.3.2 | -Which we will release. Shortly after we tighten it up. Because we love you, our users, very much. And we want to help you make your apps awesome. With webpack. + -\- The Angular-CLI team + -Oh, almost forgot. Also: +# 20.3.38 (2026-10-05) -* **tests:** allow to create component without a spec file ([a85a507](https://github.com/angular/angular-cli/commit/a85a507)), closes [#1256](https://github.com/angular/angular-cli/issues/1256) -* add module-resolver utils ([b8ddeec](https://github.com/angular/angular-cli/commit/b8ddeec)) -* add utilities for typescript ast ([#1159](https://github.com/angular/angular-cli/issues/1159)) ([0cfc2bf](https://github.com/angular/angular-cli/commit/0cfc2bf)) +### @angular/build +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | --------------------- | +| [3e39e33fb1](https://github.com/angular/angular-cli/commit/3e39e33fb1422af144ea795d871969640a27627b) | fix | bump piscina to 5.3.2 | + - -# [1.0.0-beta.10](https://github.com/angular/angular-cli/compare/1.0.0-beta.9...v1.0.0-beta.10) (2016-07-19) + +# 22.3.0-next.0 (2026-10-01) -### Bug Fixes +### @angular/cli -* **build:** don't ignore js in public ([#1129](https://github.com/angular/angular-cli/issues/1129)) ([00e111a](https://github.com/angular/angular-cli/commit/00e111a)), closes [#540](https://github.com/angular/angular-cli/issues/540) -* **mobile:** remove app/index.js from concatenated bundle ([#1267](https://github.com/angular/angular-cli/issues/1267)) ([03fd4c4](https://github.com/angular/angular-cli/commit/03fd4c4)) -* Fix all versions of dependencies to Angular-CLI ([#1331](https://github.com/angular/angular-cli/issues/1331)) ([022e7f9](https://github.com/angular/angular-cli/commit/022e7f9)), closes [#1331](https://github.com/angular/angular-cli/issues/1331) -* fix versions in the shrinkwrap instead of using ranges ([#1350](https://github.com/angular/angular-cli/issues/1350)) ([72bc9d9](https://github.com/angular/angular-cli/commit/72bc9d9)), closes [#1350](https://github.com/angular/angular-cli/issues/1350) +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------- | +| [f52fb536a1](https://github.com/angular/angular-cli/commit/f52fb536a1fddabee42448a050961c7986314992) | fix | decouple official Node.js support check from execution gate | +### @angular/build -### Features +| 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 | -* **router:** upgrade the router version ([#1288](https://github.com/angular/angular-cli/issues/1288)) ([2c9a371](https://github.com/angular/angular-cli/commit/2c9a371)) -* add get-dependent-fils utils ([6590743](https://github.com/angular/angular-cli/commit/6590743)) + + +# 22.2.1 (2026-10-01) -**Always follow the [update guide](https://github.com/angular/angular-cli/blob/master/README.md#updating-angular-cli) when updating to a new version. The changelog does not list breaking changes that are fixed via the update procedure.** +### @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 +
--- - -# [1.0.0-beta.9](https://github.com/angular/angular-cli/compare/v1.0.0-beta.6...v1.0.0-beta.9) (2016-07-04) +# 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` -### Bug Fixes +--- -* **npm:** update to npm 3.10.2 ([#1250](https://github.com/angular/angular-cli/issues/1250)) ([6f0ebfb](https://github.com/angular/angular-cli/commit/6f0ebfb)), closes [#1186](https://github.com/angular/angular-cli/issues/1186) [#1191](https://github.com/angular/angular-cli/issues/1191) [#1201](https://github.com/angular/angular-cli/issues/1201) [#1209](https://github.com/angular/angular-cli/issues/1209) [#1207](https://github.com/angular/angular-cli/issues/1207) [#1248](https://github.com/angular/angular-cli/issues/1248) -* **sass:** don't compile partials ([af9a4f9](https://github.com/angular/angular-cli/commit/af9a4f9)) +# Special Thanks +Renovate Bot, Alan Agius, Charles Lyding, Keen Yee Liau, Aravind V Nair + - -# 1.0.0-beta.7 (2016-06-23) + +# v12.0.0-next.0 (2021-02-11) -### Bug Fixes +# Commits -* **deps:** update router (#1121) ([b90a110](https://github.com/angular/angular-cli/commit/b90a110)) -* **e2e:** prevent chrome race condition (#1141) ([9df0ffe](https://github.com/angular/angular-cli/commit/9df0ffe)) -* **init:** don't replace live reload script on diffs (#1128) ([e97fd9f](https://github.com/angular/angular-cli/commit/e97fd9f)), closes [#1122](https://github.com/angular/angular-cli/issues/1122) -* **lint:** add missing rulesDirectory (#1108) ([1690a82](https://github.com/angular/angular-cli/commit/1690a82)), closes [#1094](https://github.com/angular/angular-cli/issues/1094) -* **mobile:** partially fix dep problem (#1151) ([4b638c8](https://github.com/angular/angular-cli/commit/4b638c8)), closes [#1151](https://github.com/angular/angular-cli/issues/1151) + + -### Features + + + -* add file system utilities for 'upgrade' process ([327f649](https://github.com/angular/angular-cli/commit/327f649)) + + + - -# 1.0.0-beta.6 (2016-06-15) + + + + -### Bug Fixes + -* **admin:** added support for non Administrator CLI user ([0bc3d94](https://github.com/angular/angular-cli/commit/0bc3d94)), closes [#905](https://github.com/angular/angular-cli/issues/905) [#886](https://github.com/angular/angular-cli/issues/886) [#370](https://github.com/angular/angular-cli/issues/370) -* **barrel:** alphabetized barrel exports ([67b577d](https://github.com/angular/angular-cli/commit/67b577d)), closes [#582](https://github.com/angular/angular-cli/issues/582) -* **deploy:** Fix base href for user pages (#965) ([424cff2](https://github.com/angular/angular-cli/commit/424cff2)), closes [#965](https://github.com/angular/angular-cli/issues/965) -* **e2e:** return exit codes on failure of e2e tests ([d0c07ac](https://github.com/angular/angular-cli/commit/d0c07ac)), closes [#1017](https://github.com/angular/angular-cli/issues/1017) [#1025](https://github.com/angular/angular-cli/issues/1025) [#1044](https://github.com/angular/angular-cli/issues/1044) -* **generator:** --dry-run no longer modifies files ([6efc8ee](https://github.com/angular/angular-cli/commit/6efc8ee)) -* Persist style extension config at project creation. ([85c9aec](https://github.com/angular/angular-cli/commit/85c9aec)), closes [#780](https://github.com/angular/angular-cli/issues/780) -* skips git-init if working folder is inside a git repo ([52c0cfb](https://github.com/angular/angular-cli/commit/52c0cfb)), closes [#802](https://github.com/angular/angular-cli/issues/802) -* **gh-deploy:** fix deep links (#1020) ([f8f8179](https://github.com/angular/angular-cli/commit/f8f8179)), closes [(#1020](https://github.com/(/issues/1020) [#995](https://github.com/angular/angular-cli/issues/995) -* **mobile:** add missing vendor file to build (#972) ([9a7bfe0](https://github.com/angular/angular-cli/commit/9a7bfe0)), closes [#847](https://github.com/angular/angular-cli/issues/847) -* **mobile:** lock dependency (#961) ([740805b](https://github.com/angular/angular-cli/commit/740805b)), closes [#958](https://github.com/angular/angular-cli/issues/958) -* **sourcemaps:** try to improve the source maps by fixing the path (#1028) ([5f909aa](https://github.com/angular/angular-cli/commit/5f909aa)) -* **template:** Update pipe template to include Pipe in name ([c92f330](https://github.com/angular/angular-cli/commit/c92f330)), closes [#869](https://github.com/angular/angular-cli/issues/869) + + + -### Features + -* allow lazy route prefix to be configurable ([c3fd9c7](https://github.com/angular/angular-cli/commit/c3fd9c7)), closes [#842](https://github.com/angular/angular-cli/issues/842) -* **router:** upgrade the router version ([eb9b80e](https://github.com/angular/angular-cli/commit/eb9b80e)) -* **style:** automatically add dependencies if style is set on new projects ([01e31ab](https://github.com/angular/angular-cli/commit/01e31ab)), closes [#986](https://github.com/angular/angular-cli/issues/986) -* **test:** run e2e of generated project (#490) ([d0dbd70](https://github.com/angular/angular-cli/commit/d0dbd70)) + + + + -### BREAKING CHANGES + -* The router has been updated to the newest version, usage of the deprecated router and the original release candidate routers are no longer supported + -* `AppComponent` is now simply `AppComponent`, and it's selector is now `app-root` (https://github.com/angular/angular-cli/pull/1042). + + + + + - -# 1.0.0-beta.5 (2016-05-19) + + -### Bug Fixes + -* **build:** fix broken sourcemaps (#839) ([234de2b](https://github.com/angular/angular-cli/commit/234de2b)), closes [#839](https://github.com/angular/angular-cli/issues/839) + + + -### Features + + + -* **blueprint:** add blueprint for generating interfaces (#757) ([482aa74](https://github.com/angular/angular-cli/commit/482aa74)), closes [#729](https://github.com/angular/angular-cli/issues/729) -* **test:** use link-cli option on e2e (#841) ([85d1400](https://github.com/angular/angular-cli/commit/85d1400)) + -### Performance Improvements + -* **ng new:** command to link to `angular-cli` (#778) ([9b8334f](https://github.com/angular/angular-cli/commit/9b8334f)) + + + + + + + - -# 1.0.0-beta.4 (2016-05-18) + + -### Bug Fixes + -* **build:** fix infinite loop on ng serve (#775) ([285db13](https://github.com/angular/angular-cli/commit/285db13)), closes [#775](https://github.com/angular/angular-cli/issues/775) -* **deploy:** fix file copy, index tag rewrite (#772) ([a34aca8](https://github.com/angular/angular-cli/commit/a34aca8)), closes [#772](https://github.com/angular/angular-cli/issues/772) -* **index:** fix live reload file path (#774) ([be718cb](https://github.com/angular/angular-cli/commit/be718cb)), closes [#774](https://github.com/angular/angular-cli/issues/774) -* **mobile:** don't import system-config in system-import.js (#794) ([7ab7d72](https://github.com/angular/angular-cli/commit/7ab7d72)) -* **mobile:** make app-shell compilation synchronous ([9ed28ba](https://github.com/angular/angular-cli/commit/9ed28ba)) -* **mobile:** prevent already-bundled JS from getting cached by Service Worker ([9d18f74](https://github.com/angular/angular-cli/commit/9d18f74)) + + + -### Features + + + -* **mobile:** add app shell to mobile blueprint (#809) ([e7d7ed8](https://github.com/angular/angular-cli/commit/e7d7ed8)) -* **SASSPlugin:** Allow regexes to be passed to include/exclude certain file patterns ([6b45099](https://github.com/angular/angular-cli/commit/6b45099)), closes [#558](https://github.com/angular/angular-cli/issues/558) + + + - -# 1.0.0-beta.2-mobile.3 (2016-05-13) + + + + -### Bug Fixes + -* **broccoli-typescript:** properly parse compilerOptions (#764) ([bbf1bc8](https://github.com/angular/angular-cli/commit/bbf1bc8)) -* **mobile:** include vendor scripts in bundle ([679d0e6](https://github.com/angular/angular-cli/commit/679d0e6)), closes [#733](https://github.com/angular/angular-cli/issues/733) -* **mobile:** remove mobile-specific dependencies from root package ([263e23b](https://github.com/angular/angular-cli/commit/263e23b)) -* **mobile:** update path to reflect updated service worker package (#746) ([818fb19](https://github.com/angular/angular-cli/commit/818fb19)) -* **package:** temporarily remove angular2-service-worker ([7f86ab3](https://github.com/angular/angular-cli/commit/7f86ab3)) + + + -### Features + -* **blueprints:** add enum blueprint. ([eddb354](https://github.com/angular/angular-cli/commit/eddb354)), closes [#707](https://github.com/angular/angular-cli/issues/707) + + + + + +

@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 -* Route generation is temporarily disabled while we move to the [recently announce router](http://angularjs.blogspot.ie/2016/06/improvements-coming-for-routing-in.html)(https://github.com/angular/angular-cli/pull/992). It is recommended that users manually move to this router in all new projects. + + [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 +
- -# 1.0.0-beta.2-mobile (2016-05-12) +--- +# Breaking Changes -### Bug Fixes +

+ 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. -* package.json use sourceDir for new command ([8dcd996](https://github.com/angular/angular-cli/commit/8dcd996)) -* use options sourceDir, and fix null property access. Also use 1.9 ([7ba388d](https://github.com/angular/angular-cli/commit/7ba388d)), closes [#619](https://github.com/angular/angular-cli/issues/619) -* **710:** Missing http module dependency ([c0aadae](https://github.com/angular/angular-cli/commit/c0aadae)) -* **commands:** fix outdated string utils import. ([7db40df](https://github.com/angular/angular-cli/commit/7db40df)) +

+ @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. -### Features +Before -* **mobile:** add blueprint for app manifest and icons ([f717bde](https://github.com/angular/angular-cli/commit/f717bde)) -* **mobile:** add prod build step to concatenate scripts ([51569ce](https://github.com/angular/angular-cli/commit/51569ce)) -* **mobile:** add ServiceWorker generation to build process and index ([04593eb](https://github.com/angular/angular-cli/commit/04593eb)) -* **mobile:** add support for generating App Shell in index.html ([cb1270f](https://github.com/angular/angular-cli/commit/cb1270f)) -* **ng2 blueprint:** add test script entry to package.json ([eabc160](https://github.com/angular/angular-cli/commit/eabc160)) +```js +import img from './images/asset.png'; +``` +After +```html + +``` - -# 1.0.0-beta.1 (2016-05-07) +--- +# Special Thanks -### Bug Fixes +Renovate Bot, Charles Lyding, Alan Agius, Doug Parker, Bruno Baia, Amadou Sall, S. Iftekhar Hossain -* **generated-project:** cli was not using the correct version of CLI in generated project. (#672) ([02073ae](https://github.com/angular/angular-cli/commit/02073ae)) +--- +**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 e43ebf41390e..8b57f53f5584 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ -# Contributing to angular-cli +# Contributing to Angular DevKit -We would love for you to contribute to angular-cli and help make it even better +We would love for you to contribute to DevKit and help make it even better than it is today! As a contributor, here are the guidelines we would like you to follow: @@ -12,13 +12,28 @@ to follow: - [Coding Rules](#rules) - [Commit Message Guidelines](#commit) - [Signing the CLA](#cla) + - [Spam Policy](#spam-policy) ## Code of Conduct Help us keep Angular open and inclusive. Please read and follow our [Code of Conduct][coc]. ## Got a Question or Problem? -If you have questions about how to *use* Angular CLI, please direct them to [StackOverflow][stackoverflow]. Please note that angular-cli is still in early developer preview, and the core team's capacity to answer usage questions is limited. We are also available on [Gitter][gitter]. +Please, do not open issues for the general support questions as we want to keep GitHub issues for +bug reports and feature requests. You've got much better chances of getting your question answered +on [StackOverflow](https://stackoverflow.com/questions/tagged/angular-devkit) where the questions +should be tagged with tag `angular-cli` or `angular-devkit`. + +StackOverflow is a much better place to ask questions since: + +- There are thousands of people willing to help on StackOverflow. +- Questions and answers stay available for public viewing so your question / answer might help someone else. +- StackOverflow's voting system assures that the best answers are prominently visible. + +To save your and our time we will be systematically closing all the issues that are requests for +general support and redirecting people to StackOverflow. + +If you would like to chat about the question in real-time, you can reach out via [our gitter channel][gitter]. ## Found an Issue? If you find a bug in the source code or a mistake in the documentation, you can help us by @@ -28,8 +43,7 @@ If you find a bug in the source code or a mistake in the documentation, you can ## Want a Feature? You can *request* a new feature by [submitting an issue](#submit-issue) to our [GitHub Repository][github]. If you would like to *implement* a new feature, please submit an issue with -a proposal for your work first, to be sure that we can use it. Angular CLI is in developer preview -and we are not ready to accept major contributions ahead of the full release. +a proposal for your work first, to be sure that we can use it. Please consider what kind of change it is: * For a **Major Feature**, first open an issue and outline your proposal so that it can be @@ -40,24 +54,24 @@ and help you to craft the change so that it is successfully accepted into the pr ## Submission Guidelines ### Submitting an Issue -Before you submit an issue, search the archive, maybe your question was already answered. -If your issue appears to be a bug, and hasn't been reported, open a new issue. -Help us to maximize the effort we can spend fixing issues and adding new -features, by not reporting duplicate issues. Providing the following information will increase the -chances of your issue being dealt with quickly: +Before you submit an issue, please search the issue tracker, maybe an issue for your problem already exists and the discussion might inform you of workarounds readily available. + +We want to fix all the issues as soon as possible, but before fixing a bug we need to reproduce and confirm it. Having a reproducible scenario gives us wealth of important information without going back & forth to you with additional questions like: -* **Overview of the Issue** - if an error is being thrown a non-minified stack trace helps -* **Angular CLI Version** - what version of the CLI is affected (e.g. 0.1.2) -* **Motivation for or Use Case** - explain what are you trying to do and why the current behavior is a bug for you -* **Browsers and Operating System** - is this a problem with all browsers? -* **Reproduce the Error** - provide a live example (using [Plunker][plunker], - [JSFiddle][jsfiddle] or [Runnable][runnable]) or a unambiguous set of steps -* **Related Issues** - has a similar issue been reported before? -* **Suggest a Fix** - if you can't fix the bug yourself, perhaps you can point to what might be - causing the problem (line of code or commit) +- version of Angular CLI used +- `angular.json` configuration +- version of Angular DevKit used +- 3rd-party libraries and their versions +- and most importantly - a use-case that fails -You can file new issues by providing the above information [here](https://github.com/angular/angular-cli/issues/new). +A minimal reproduce scenario using allows us to quickly confirm a bug (or point out coding problem) as well as confirm that we are fixing the right problem. + +We will be insisting on a minimal reproduce scenario in order to save maintainers time and ultimately be able to fix more bugs. Interestingly, from our experience users often find coding problems themselves while preparing a minimal repository. We understand that sometimes it might be hard to extract essentials bits of code from a larger code-base but we really need to isolate the problem before we can fix it. + +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 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) @@ -70,12 +84,12 @@ Before you submit your Pull Request (PR) consider the following guidelines: * Make your changes in a new git branch: ```shell - git checkout -b my-fix-branch master + git checkout -b my-fix-branch main ``` * Create your patch, **including appropriate test cases**. * Follow our [Coding Rules](#rules). -* Run the full Angular CLI test suite, as described in the [developer documentation][dev-doc], +* Run the full Angular CLI and DevKit test suite, as described in the [developer documentation][dev-doc], and ensure that all tests pass (coming soon). * Commit your changes using a descriptive commit message that follows our [commit message conventions](#commit). Adherence to these conventions @@ -92,18 +106,25 @@ Before you submit your Pull Request (PR) consider the following guidelines: git push origin my-fix-branch ``` -* In GitHub, send a pull request to `angular-cli:master`. +* In GitHub, send a pull request to `angular/angular-cli:main`. * If we suggest changes then: * Make the required updates. - * Re-run the Angular CLI test suites for JS and Dart to ensure tests are still passing. - * Rebase your branch and force push to your GitHub repository (this will update your Pull Request): + * Re-run the Angular DevKit test suites to ensure tests are still passing. +* Once your PR is approved and you are done with any follow up changes: + * Rebase to the current main to pre-emptively address any merge conflicts. ```shell - git rebase master -i + git rebase upstream/main -i git push -f ``` + * Add the `action: merge` label and the correct +[target label](https://github.com/angular/angular/blob/main/docs/TRIAGE_AND_LABELS.md#pr-target) + (if PR author has the project collaborator status, or else the last reviewer + should do this). + * The current caretaker will merge the PR to the target branch(es) within 1-2 + business days. -That's it! Thank you for your contribution! +That's it! 🎉 Thank you for your contribution! #### After your pull request is merged @@ -116,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: @@ -128,16 +149,16 @@ 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 To ensure consistency throughout the source code, keep these rules in mind as you are working: -* All features or bug fixes **must be tested** by one or more specs (unit-tests). +* All features or bug fixes **must be tested** by one or more specs (unit-tests or e2e-tests). * All public API methods **must be documented**. (Details TBC). * We follow [Google's JavaScript Style Guide][js-style-guide], but wrap all code at **100 characters**. @@ -146,7 +167,7 @@ To ensure consistency throughout the source code, keep these rules in mind as yo We have very precise rules over how our git commit messages can be formatted. This leads to **more readable messages** that are easy to follow when looking through the **project history**. But also, -we use the git commit messages to **generate the Angular CLI change log**. +we use the git commit messages to **generate the Angular DevKit change log**. ### Commit Message Format Each commit message consists of a **header**, a **body** and a **footer**. The header has a special @@ -171,39 +192,93 @@ If the commit reverts a previous commit, it should begin with `revert: `, follow ### Type Must be one of the following: -* **feat**: A new feature -* **fix**: A bug fix -* **docs**: Documentation only changes -* **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing - semi-colons, etc) -* **refactor**: A code change that neither fixes a bug nor adds a feature -* **perf**: A code change that improves performance -* **test**: Adding missing tests or correcting existing tests -* **build** Changes that affect the build system, CI configuration or external dependencies (example scopes: gulp, broccoli, npm) -* **chore**: Other changes that don't modify `src` or `test` files +* **build**: Changes to local repository build system and tooling +* **ci**: Changes to CI configuration and CI specific tooling [2] +* **docs**: Changes which exclusively affects documentation. +* **feat**: Creates a new feature [1] +* **fix**: Fixes a previously discovered failure/bug [1] +* **perf**: Improves performance without any change in functionality or API [1] +* **refactor**: Refactor without any change in functionality or API (includes style changes) +* **release**: A release point in the repository [2] +* **test**: Improvements or corrections made to the project's test suite + + +[1] This type MUST have a scope. See the next section for more information.
+[2] This type MUST NOT have a scope. It only applies to general scripts and tooling. ### Scope -The scope could be anything specifying place of the commit change. For example -`Compiler`, `ElementInjector`, etc. +The scope should be the name of the npm package affected as perceived by the person reading changelog generated from the commit messages. + +The following is the list of supported scopes: + +* **@angular/build** +* **@angular/cli** +* **@angular/create** +* **@angular/pwa** +* **@angular/ssr** +* **@angular-devkit/architect** +* **@angular-devkit/build-angular** +* **@angular-devkit/build-webpack** +* **@angular-devkit/core** +* **@angular-devkit/schematics** +* **@angular-devkit/schematics-cli** +* **@ngtools/webpack** +* **@schematics/angular** + ### Subject The subject contains succinct description of the change: * use the imperative, present tense: "change" not "changed" nor "changes" * don't capitalize first letter +* be concise and direct * no dot (.) at the end +### Examples +Examples of valid commit messages: + +* `fix(@angular/cli): prevent the flubber from grassing` +* `refactor(@schematics/angular): move all JSON classes together` + +Examples of invalid commit messages: +* `fix(@angular/cli): add a new XYZ command` + + This is a feature, not a fix. +* `ci(@angular/cli): fix publishing workflow` + + The `ci` type cannot have a scope. + ### Body Just as in the **subject**, use the imperative, present tense: "change" not "changed" nor "changes". The body should include the motivation for the change and contrast this with previous behavior. ### Footer -The footer should contain any information about **Breaking Changes** and is also the place to -reference GitHub issues that this commit **Closes**. +The footer can contain information about breaking changes and deprecations. It is also the place to reference GitHub issues, Jira tickets, and other PRs that are related to this commit or that this commit will close. +For example: + +``` +BREAKING CHANGE: + + + + +Fixes # +``` + +or -**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. +``` +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. -A detailed explanation can be found in this [document][commit-message-format]. +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 @@ -214,16 +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 -[coc]: https://github.com/angular/code-of-conduct/blob/master/CODE_OF_CONDUCT.md +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/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/blob/master/DEVELOPER.md -[github]: https://github.com/angular/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 -[jsfiddle]: http://jsfiddle.net/ -[plunker]: http://plnkr.co/edit -[runnable]: http://runnable.com/ -[stackoverflow]: http://stackoverflow.com/questions/tagged/angular-cli +[stackoverflow]: https://stackoverflow.com/questions/tagged/angular-devkit + diff --git a/LICENSE b/LICENSE index 13a6fd789f83..5ec8e26b9eec 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License -Copyright (c) 2016 Google, Inc. +Copyright (c) 2010-2026 Google LLC. https://angular.dev/license Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MODULE.bazel b/MODULE.bazel new file mode 100644 index 000000000000..0cf0f1fbf03a --- /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 = "d45884cba93817d0e1a2be67ca5638227f64e508", + 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 ea3c808061a8..9d3364263312 100644 --- a/README.md +++ b/README.md @@ -1,495 +1,196 @@ -## Angular-CLI + -## Note +

Angular CLI - The CLI tool for Angular.

-This project is very much still a work in progress. +

+
+ 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.
+
+

-The CLI is now in beta. -If you wish to collaborate while the project is still young, check out [our issue list](https://github.com/angular/angular-cli/issues). +

+ angular.dev/tools/cli +
+

-Before submitting new issues, have a look at [issues marked with the `type: faq` label](https://github.com/angular/angular-cli/issues?utf8=%E2%9C%93&q=is%3Aissue%20label%3A%22type%3A%20faq%22%20). +

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

-## Webpack update +
-We changed the build system between beta.10 and beta.14, from SystemJS to Webpack. -And with it comes a lot of benefits. -To take advantage of these, your app built with the old beta will need to migrate. +## Documentation -You can update your `beta.10` projects to `beta.14` by following [these instructions](https://github.com/angular/angular-cli/wiki/Upgrading-from-Beta.10-to-Beta.14). +Get started with Angular CLI, learn the fundamentals and explore advanced topics on our documentation website. -## Prerequisites +- [Getting started][quickstart] +- [CLI][cli] +- [Workspace and project file structure][filestructure] +- [Workspace configuration][workspaceconfig] +- [Schematics][schematics] -Both the CLI and generated project have dependencies that require Node 4 or higher, together -with NPM 3 or higher. +## Development Setup -## Table of Contents -* [Installation](#installation) -* [Usage](#usage) -* [Generating a New Project](#generating-and-serving-an-angular2-project-via-a-development-server) -* [Generating Components, Directives, Pipes and Services](#generating-components-directives-pipes-and-services) -* [Generating a Route](#generating-a-route) -* [Creating a Build](#creating-a-build) -* [Build Targets and Environment Files](#build-targets-and-environment-files) -* [Base tag handling in index.html](#base-tag-handling-in-indexhtml) -* [Bundling](#bundling) -* [Running Unit Tests](#running-unit-tests) -* [Running End-to-End Tests](#running-end-to-end-tests) -* [Proxy To Backend](#proxy-to-backend) -* [Deploying the App via GitHub Pages](#deploying-the-app-via-github-pages) -* [Linting and formatting code](#linting-and-formatting-code) -* [Support for offline applications](#support-for-offline-applications) -* [Commands autocompletion](#commands-autocompletion) -* [Project assets](#project-assets) -* [Global styles](#global-styles) -* [CSS preprocessor integration](#css-preprocessor-integration) -* [3rd Party Library Installation](#3rd-party-library-installation) -* [Global Library Installation](#global-library-installation) -* [Updating angular-cli](#updating-angular-cli) -* [Development Hints for hacking on angular-cli](#development-hints-for-hacking-on-angular-cli) +### Prerequisites -## Installation +- Install [Node.js] which includes [Node Package Manager][npm] -**BEFORE YOU INSTALL:** please read the [prerequisites](#prerequisites) -```bash -npm install -g angular-cli -``` - -## Usage - -```bash -ng help -``` - -### Generating and serving an Angular2 project via a development server - -```bash -ng new PROJECT_NAME -cd PROJECT_NAME -ng serve -``` -Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. - -You can configure the default HTTP port and the one used by the LiveReload server with two command-line options : - -```bash -ng serve --host 0.0.0.0 --port 4201 --live-reload-port 49153 -``` - -### Generating Components, Directives, Pipes and Services - -You can use the `ng generate` (or just `ng g`) command to generate Angular components: - -```bash -ng generate component my-new-component -ng g component my-new-component # using the alias - -# components support relative path generation -# if in the directory src/app/feature/ and you run -ng g component new-cmp -# your component will be generated in src/app/feature/new-cmp -# but if you were to run -ng g component ../newer-cmp -# your component will be generated in src/app/newer-cmp -``` -You can find all possible blueprints in the table below: - -Scaffold | Usage ---- | --- -Component | `ng g component my-new-component` -Directive | `ng g directive my-new-directive` -Pipe | `ng g pipe my-new-pipe` -Service | `ng g service my-new-service` -Class | `ng g class my-new-class` -Interface | `ng g interface my-new-interface` -Enum | `ng g enum my-new-enum` -Module | `ng g module my-module` - -### Generating a route - -Generating routes in the CLI has been disabled for the time being. A new router and new route generation blueprints are coming. - -You can read the official documentation for the new Router here: https://angular.io/docs/ts/latest/guide/router.html. Please note that even though route generation is disabled, building your projects with routing is still fully supported. - -### Creating a build - -```bash -ng build -``` - -The build artifacts will be stored in the `dist/` directory. - -### 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. +### Setting Up a Project -The mapping used to determine which environment file is used can be found in `angular-cli.json`: +Install the Angular CLI globally: -```json -"environments": { - "source": "environments/environment.ts", - "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 - -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`. - -You can run tests with coverage via `--code-coverage`. The coverage report will be in the `coverage/` directory. - -Linting during tests is also available via the `--lint` flag. See [Linting and formatting code](#linting-and-formatting-code) chapter for more informations. - -### Running end-to-end tests - -```bash -ng e2e +npm install -g @angular/cli ``` -Before running the tests make sure you are serving the app via `ng serve`. - -End-to-end tests are run via [Protractor](https://angular.github.io/protractor/). - -### Proxy To Backend -Using the proxying support in webpack's dev server we can highjack certain urls and send them to a backend server. -We do this by passing a file to `--proxy-config` - -Say we have a server running on `http://localhost:3000/api` and we want all calls to `http://localhost:4200/api` to go to that server. +Create workspace: -We create a file next to projects `package.json` called `proxy.conf.json` -with the content - -```json -{ - "/api": { - "target": "http://localhost:3000", - "secure": false - } -} ``` - -You can read more about what options are available here [webpack-dev-server proxy settings](https://webpack.github.io/docs/webpack-dev-server.html#proxy) - -and then we edit the `package.json` file's start script to be - -```json -"start": "ng serve --proxy-config proxy.conf.json", +ng new [PROJECT NAME] ``` -now run it with `npm start` - -### Deploying the app via GitHub Pages - -You can deploy your apps quickly via: +Run the application: -```bash -ng github-pages:deploy --message "Optional commit message" ``` - -This will do the following: - -- creates GitHub repo for the current project if one doesn't exist -- rebuilds the app in production mode at the current `HEAD` -- creates a local `gh-pages` branch if one doesn't exist -- moves your app to the `gh-pages` branch and creates a commit -- edit the base tag in index.html to support github pages -- pushes the `gh-pages` branch to github -- returns back to the original `HEAD` - -Creating the repo requires a token from github, and the remaining functionality -relies on ssh authentication for all git operations that communicate with github.com. -To simplify the authentication, be sure to [setup your ssh keys](https://help.github.com/articles/generating-ssh-keys/). - -If you are deploying a [user or organization page](https://help.github.com/articles/user-organization-and-project-pages/), you can instead use the following command: - -```bash -ng github-pages:deploy --user-page --message "Optional commit message" -``` - -This command pushes the app to the `master` branch on the github repo instead -of pushing to `gh-pages`, since user and organization pages require this. - - -### Linting and formatting code - -You can lint your app code by running `ng lint`. -This will use the `lint` npm script that in generated projects uses `tslint`. - -You can modify the these scripts in `package.json` to run whatever tool you prefer. - -### Support for offline applications - -**The `--mobile` flag has been disabled temporarily. Sorry for the inconvenience.** - -~~Angular-CLI includes support for offline applications via the `--` flag on `ng new`. Support is experimental, please see the angular/mobile-toolkit project and https://mobile.angular.io/ for documentation on how to make use of this functionality.~~ - -### Commands autocompletion - -To turn on auto completion use the following commands: - -For bash: -```bash -ng completion 1>> ~/.bashrc 2>>&1 -source ~/.bashrc -``` - -For zsh: -```bash -ng completion 1>> ~/.zshrc 2>>&1 -source ~/.zshrc -``` - -Windows users using gitbash: -```bash -ng completion 1>> ~/.bash_profile 2>>&1 -source ~/.bash_profile -``` - -### 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: -```json -"assets": [ - "assets", - "favicon.ico" -] -``` - -### Global styles - -The `styles.css` file allows users to add global styles and supports -[CSS imports](https://developer.mozilla.org/en/docs/Web/CSS/@import). - -If the project is created with the `--style=sass` option, this will be a `.sass` -file instead, and the same applies to `scss/less/styl`. - -You can add more global styles via the `apps[0].styles` property in `angular-cli.json`. - -### 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 +cd [PROJECT NAME] +ng serve ``` -### 3rd Party Library Installation - -Simply install your library via `npm install lib-name --save` and import it in your code. - -If the library does not include typings, you can install them using npm: +Angular is cross-platform, fast, scalable, has incredible tooling, and is loved by millions. -```bash -npm install d3 --save -npm install @types/d3 --save-dev -``` +## Quickstart -If the library doesn't have typings available at `@types/`, you can still use it by -manually adding typings for it: +[Get started in 5 minutes][quickstart]. -1. First, create a `typings.d.ts` file in your `src/` folder. This file will be automatically included as global type definition. +## Ecosystem -2. Then, in `src/typings.d.ts`, add the following code: +

+ angular ecosystem logos +

- ```typescript - declare module 'typeless-package'; - ``` +- [Angular Framework][adev] +- [Angular Material][angularmaterial] -3. Finally, in the component or file that uses the library, add the following code: +## Changelog - ```typescript - import * as typelessPackage from 'typeless-package'; - typelessPackage.method(); - ``` +[Learn about the latest improvements][changelog]. -Done. Note: you might need or find useful to define more typings for the library that you're trying to use. +## Upgrading -### Global Library Installation +Check out our [upgrade guide](https://update.angular.dev/) to find out the best way to upgrade your project. -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`. +## Contributing -As an example, to use [Bootstrap 4](http://v4-alpha.getbootstrap.com/) this is -what you need to do: +### Contributing Guidelines -First install Bootstrap from `npm`: +Read through our [contributing guidelines][contributing] to learn about our submission process, coding rules and more. -```bash -npm install bootstrap@next -``` +### Want to Help? -Then add the needed script files to `apps[0].scripts`: +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). -```json -"scripts": [ - "../node_modules/jquery/dist/jquery.js", - "../node_modules/tether/dist/js/tether.js", - "../node_modules/bootstrap/dist/js/bootstrap.js" -], -``` +### Code of Conduct -Finally add the Bootstrap CSS to the `apps[0].styles` array: -```json -"styles": [ - "../node_modules/bootstrap/dist/css/bootstrap.css", - "styles.css" -], -``` +Help us keep Angular open and inclusive. Please read and follow our [Code of Conduct][codeofconduct]. -Restart `ng serve` if you're running it, and Bootstrap 4 should be working on -your app. +### Developer Guide -### Updating angular-cli +Read through our [developer guide][developer] to learn about how to build and test the Angular CLI locally. -To update `angular-cli` to a new version, you must update both the global package and your project's local package. -Global package: -```bash -npm uninstall -g angular-cli -npm cache clean -npm install -g angular-cli@latest -``` +## Community -Local project package: -```bash -rm -rf node_modules dist tmp -npm install --save-dev angular-cli@latest -npm install -ng init -``` +Join the conversation and help the community. -Running `ng init` will check for changes in all the auto-generated files created by `ng new` and allow you to update yours. You are offered four choices for each changed file: `y` (overwrite), `n` (don't overwrite), `d` (show diff between your file and the updated file) and `h` (help). +- [X (formerly Twitter)][twitter] +- [Discord][discord] +- [Gitter][gitter] +- [YouTube][youtube] +- [StackOverflow][stackoverflow] +- Find a Local [Meetup][meetup] -Carefully read the diffs for each code file, and either accept the changes or incorporate them manually after `ng init` finishes. +## Packages -**The main cause of errors after an update is failing to incorporate these updates into your code**. +This is a monorepo which contains many tools and packages: -You can find more details about changes between versions in [CHANGELOG.md](https://github.com/angular/angular-cli/blob/master/CHANGELOG.md). -## Development Hints for hacking on angular-cli +### Tools -### Working with master +| Project | Package | Version | Links | +|---|---|---|---| +**Angular Build System** | [`@angular/build`](https://npmjs.com/package/@angular/build) | [![latest](https://img.shields.io/npm/v/%40angular%2Fbuild/latest.svg)](https://npmjs.com/package/@angular/build) | [![README](https://img.shields.io/badge/README--green.svg)](/packages/angular/build/README.md) [![snapshot](https://img.shields.io/badge/snapshot--blue.svg)](https://github.com/angular/angular-build-builds) +**Angular CLI** | [`@angular/cli`](https://npmjs.com/package/@angular/cli) | [![latest](https://img.shields.io/npm/v/%40angular%2Fcli/latest.svg)](https://npmjs.com/package/@angular/cli) | [![README](https://img.shields.io/badge/README--green.svg)](/packages/angular/cli/README.md) [![snapshot](https://img.shields.io/badge/snapshot--blue.svg)](https://github.com/angular/cli-builds) +**Schematics CLI** | [`@angular-devkit/schematics-cli`](https://npmjs.com/package/@angular-devkit/schematics-cli) | [![latest](https://img.shields.io/npm/v/%40angular-devkit%2Fschematics-cli/latest.svg)](https://npmjs.com/package/@angular-devkit/schematics-cli) | [![snapshot](https://img.shields.io/badge/snapshot--blue.svg)](https://github.com/angular/angular-devkit-schematics-cli-builds) -```bash -git clone https://github.com/angular/angular-cli.git -cd angular-cli -npm link -``` -`npm link` is very similar to `npm install -g` except that instead of downloading the package -from the repo, the just cloned `angular-cli/` folder becomes the global package. -Any changes to the files in the `angular-cli/` folder will immediately affect the global `angular-cli` package, -allowing you to quickly test any changes you make to the cli project. +### Packages -Now you can use `angular-cli` via the command line: -```bash -ng new foo -cd foo -npm link angular-cli -ng serve -``` +| Project | Package | Version | Links | +|---|---|---|---| +**Angular SSR** | [`@angular/ssr`](https://npmjs.com/package/@angular/ssr) | [![latest](https://img.shields.io/npm/v/%40angular%2Fssr/latest.svg)](https://npmjs.com/package/@angular/ssr) | [![README](https://img.shields.io/badge/README--green.svg)](/packages/angular/ssr/README.md) [![snapshot](https://img.shields.io/badge/snapshot--blue.svg)](https://github.com/angular/angular-ssr-builds) +**Architect** | [`@angular-devkit/architect`](https://npmjs.com/package/@angular-devkit/architect) | [![latest](https://img.shields.io/npm/v/%40angular-devkit%2Farchitect/latest.svg)](https://npmjs.com/package/@angular-devkit/architect) | [![README](https://img.shields.io/badge/README--green.svg)](/packages/angular_devkit/architect/README.md) [![snapshot](https://img.shields.io/badge/snapshot--blue.svg)](https://github.com/angular/angular-devkit-architect-builds) +**Build Angular** | [`@angular-devkit/build-angular`](https://npmjs.com/package/@angular-devkit/build-angular) | [![latest](https://img.shields.io/npm/v/%40angular-devkit%2Fbuild-angular/latest.svg)](https://npmjs.com/package/@angular-devkit/build-angular) | [![README](https://img.shields.io/badge/README--green.svg)](/packages/angular_devkit/build_angular/README.md) [![snapshot](https://img.shields.io/badge/snapshot--blue.svg)](https://github.com/angular/angular-devkit-build-angular-builds) +**Build Webpack** | [`@angular-devkit/build-webpack`](https://npmjs.com/package/@angular-devkit/build-webpack) | [![latest](https://img.shields.io/npm/v/%40angular-devkit%2Fbuild-webpack/latest.svg)](https://npmjs.com/package/@angular-devkit/build-webpack) | [![README](https://img.shields.io/badge/README--green.svg)](/packages/angular_devkit/build_webpack/README.md) [![snapshot](https://img.shields.io/badge/snapshot--blue.svg)](https://github.com/angular/angular-devkit-build-webpack-builds) +**Core** | [`@angular-devkit/core`](https://npmjs.com/package/@angular-devkit/core) | [![latest](https://img.shields.io/npm/v/%40angular-devkit%2Fcore/latest.svg)](https://npmjs.com/package/@angular-devkit/core) | [![README](https://img.shields.io/badge/README--green.svg)](/packages/angular_devkit/core/README.md) [![snapshot](https://img.shields.io/badge/snapshot--blue.svg)](https://github.com/angular/angular-devkit-core-builds) +**Schematics** | [`@angular-devkit/schematics`](https://npmjs.com/package/@angular-devkit/schematics) | [![latest](https://img.shields.io/npm/v/%40angular-devkit%2Fschematics/latest.svg)](https://npmjs.com/package/@angular-devkit/schematics) | [![README](https://img.shields.io/badge/README--green.svg)](/packages/angular_devkit/schematics/README.md) [![snapshot](https://img.shields.io/badge/snapshot--blue.svg)](https://github.com/angular/angular-devkit-schematics-builds) -`npm link angular-cli` is needed because by default the globally installed `angular-cli` just loads -the local `angular-cli` from the project which was fetched remotely from npm. -`npm link angular-cli` symlinks the global `angular-cli` package to the local `angular-cli` package. -Now the `angular-cli` you cloned before is in three places: -The folder you cloned it into, npm's folder where it stores global packages and the `angular-cli` project you just created. +#### Misc -You can also use `ng new foo --link-cli` to automatically link the `angular-cli` package. +| Project | Package | Version | Links | +|---|---|---|---| +**Angular Create** | [`@angular/create`](https://npmjs.com/package/@angular/create) | [![latest](https://img.shields.io/npm/v/%40angular%2Fcreate/latest.svg)](https://npmjs.com/package/@angular/create) | [![README](https://img.shields.io/badge/README--green.svg)](/packages/angular/create/README.md) +**Webpack Angular Plugin** | [`@ngtools/webpack`](https://npmjs.com/package/@ngtools/webpack) | [![latest](https://img.shields.io/npm/v/%40ngtools%2Fwebpack/latest.svg)](https://npmjs.com/package/@ngtools/webpack) | [![snapshot](https://img.shields.io/badge/snapshot--blue.svg)](https://github.com/angular/ngtools-webpack-builds) -Please read the official [npm-link documentation](https://www.npmjs.org/doc/cli/npm-link.html) -and the [npm-link cheatsheet](http://browsenpm.org/help#linkinganynpmpackagelocally) for more information. +#### Schematics +| Project | Package | Version | Links | +|---|---|---|---| +**Angular PWA Schematics** | [`@angular/pwa`](https://npmjs.com/package/@angular/pwa) | [![latest](https://img.shields.io/npm/v/%40angular%2Fpwa/latest.svg)](https://npmjs.com/package/@angular/pwa) | [![snapshot](https://img.shields.io/badge/snapshot--blue.svg)](https://github.com/angular/angular-pwa-builds) +**Angular Schematics** | [`@schematics/angular`](https://npmjs.com/package/@schematics/angular) | [![latest](https://img.shields.io/npm/v/%40schematics%2Fangular/latest.svg)](https://npmjs.com/package/@schematics/angular) | [![snapshot](https://img.shields.io/badge/snapshot--blue.svg)](https://github.com/angular/schematics-angular-builds) -## License -MIT +**Love Angular CLI? Give our repo a star :star: :arrow_up:.** -[travis-badge]: https://travis-ci.org/angular/angular-cli.svg?branch=master -[travis-badge-url]: https://travis-ci.org/angular/angular-cli -[david-badge]: https://david-dm.org/angular/angular-cli.svg -[david-badge-url]: https://david-dm.org/angular/angular-cli -[david-dev-badge]: https://david-dm.org/angular/angular-cli/dev-status.svg -[david-dev-badge-url]: https://david-dm.org/angular/angular-cli?type=dev -[npm-badge]: https://img.shields.io/npm/v/angular-cli.svg -[npm-badge-url]: https://www.npmjs.com/package/angular-cli +[contributing]: CONTRIBUTING.md +[developer]: docs/DEVELOPER.md +[quickstart]: https://angular.dev/tutorials/learn-angular +[changelog]: CHANGELOG.md +[documentation]: https://angular.dev/overview +[angularmaterial]: https://material.angular.dev/ +[cli]: https://angular.dev/tools/cli +[adev]: https://angular.dev/ +[workspaceconfig]: https://angular.dev/reference/configs/workspace-config +[schematics]: https://angular.dev/tools/cli/schematics +[filestructure]: https://angular.dev/reference/configs/file-structure +[node.js]: https://nodejs.org/ +[npm]: https://www.npmjs.com/get-npm +[codeofconduct]: https://github.com/angular/angular/blob/main/CODE_OF_CONDUCT.md +[twitter]: https://www.x.com/angular +[discord]: https://discord.gg/angular +[gitter]: https://gitter.im/angular/angular-cli +[stackoverflow]: https://stackoverflow.com/questions/tagged/angular-cli +[youtube]: https://youtube.com/angular +[meetup]: https://www.meetup.com/find/?keywords=angular diff --git a/REPO.bazel b/REPO.bazel new file mode 100644 index 000000000000..9eb8128879bd --- /dev/null +++ b/REPO.bazel @@ -0,0 +1,5 @@ +ignore_directories([ + ".git", + "dist", + "**/node_modules/**", +]) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000000..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/bin/ng b/bin/ng deleted file mode 100755 index 5e770a2caa95..000000000000 --- a/bin/ng +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -// Provide a title to the process in `ps` -process.title = 'angular-cli'; - -require('../lib/bootstrap-local'); -require('../packages/angular-cli/bin/ng'); diff --git a/constants.bzl b/constants.bzl new file mode 100644 index 000000000000..ee06dec88ac6 --- /dev/null +++ b/constants.bzl @@ -0,0 +1,31 @@ +# Engine versions to stamp in a release package.json +RELEASE_ENGINES_NODE = "^22.22.3 || ^24.15.0 || >=26.0.0" +RELEASE_ENGINES_NPM = "^6.11.0 || ^7.5.6 || >=8.0.0" +RELEASE_ENGINES_YARN = ">= 1.13.0" + +NG_PACKAGR_VERSION = "^22.3.0-next.0" +ANGULAR_FW_VERSION = "^22.3.0-next.0" +ANGULAR_FW_PEER_DEP = "^22.0.0 || ^22.3.0-next.0" +NG_PACKAGR_PEER_DEP = "^22.0.0 || ^22.3.0-next.0" + +# Baseline widely-available date in `YYYY-MM-DD` format which defines Angular's +# browser support. This date serves as the source of truth for the Angular CLI's +# default browser set used to determine what downleveling is necessary. +# +# See: https://web.dev/baseline +BASELINE_DATE = "2026-05-07" + +SNAPSHOT_REPOS = { + "@angular/cli": "angular/cli-builds", + "@angular/pwa": "angular/angular-pwa-builds", + "@angular/build": "angular/angular-build-builds", + "@angular/ssr": "angular/angular-ssr-builds", + "@angular-devkit/architect": "angular/angular-devkit-architect-builds", + "@angular-devkit/build-angular": "angular/angular-devkit-build-angular-builds", + "@angular-devkit/build-webpack": "angular/angular-devkit-build-webpack-builds", + "@angular-devkit/core": "angular/angular-devkit-core-builds", + "@angular-devkit/schematics": "angular/angular-devkit-schematics-builds", + "@angular-devkit/schematics-cli": "angular/angular-devkit-schematics-cli-builds", + "@ngtools/webpack": "angular/ngtools-webpack-builds", + "@schematics/angular": "angular/schematics-angular-builds", +} diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md new file mode 100644 index 000000000000..ddb8341e662b --- /dev/null +++ b/docs/DEVELOPER.md @@ -0,0 +1,189 @@ +# Building and Testing Angular CLI + +## Installation + +To get started locally, follow these instructions: + +1. If you haven't done it already, [make a fork of this repo](https://github.com/angular/angular-cli/fork). +2. If you are on Windows, see [the extra steps needed for contributing on Windows](#windows) +3. Clone to your local computer using `git`. +4. Make sure that you have Node `v22.22.3` or higher installed (see `engines` in `package.json`). See instructions [here](https://nodejs.org/en/download/). +5. Install `pnpm`. + - You can install pnpm by running `npm i -g pnpm@12`. + - See detailed instructions [here](https://pnpm.io/installation). +6. Run `pnpm install` from the root of your clone of this project to install dependencies. + +## Building and Installing the CLI + +To make a local build: + +```shell +pnpm build --local +``` + +This generates a number of tarballs in the `dist/` directory. To actually use +the locally built tools, switch to another repository reproducing the specific +issue you want to fix (or just generate a local repo with `ng new`). Then +install the locally built packages: + +```shell +cd "${EXAMPLE_ANGULAR_PROJECT_REPO}" +npm install -D ${CLI_REPO}/dist/*.tgz +``` + +Builds of this example project will use tooling created from the previous local +build and include any local changes. When using the CLI, it will automatically +check for a local install and use that if present. This means you can just run: + +```shell +npm install -g @angular/cli +``` + +to get a global install of the latest CLI release. Then running any `ng` command +in the example project will automatically find and use the local build of the +CLI. + +Note: If you are testing `ng update`, be aware that installing all the tarballs +will also update the framework (`@angular/core`) to the latest version. In this +case, simply install the CLI alone with +`npm install -D ${CLI_REPO}/dist/_angular_cli.tgz`, that way the rest of the +project remains to be upgraded with `ng update`. + +## Debugging + +To debug an invocation of the CLI, [build and install the CLI for an example +project](#building-and-installing-the-cli), then run the desired `ng` command +as: + +```shell +node --inspect-brk node_modules/.bin/ng +``` + +This will trigger a breakpoint as the CLI starts up. You can connect to this +using the supported mechanisms for your IDE, but the simplest option is to open +Chrome to [chrome://inspect](chrome://inspect) and then click on the `inspect` +link for the `node_modules/.bin/ng` Node target. + +Unfortunately, the CLI dynamically `require()`'s other files mid-execution, so +the debugger is not aware of all the source code files before hand. As a result, +it is tough to put breakpoints on files before the CLI loads them. The easiest +workaround is to use the `debugger;` statement to stop execution in the file you +are interested in, and then you should be able to step around and set breakpoints +as expected. + +## Testing + +There are two different test suites which can be run locally: + +### Unit tests + +- Run all tests: `pnpm bazel test //packages/...` +- Run a subset of the tests, use the full Bazel target example: `pnpm bazel test //packages/schematics/angular:test` +- For a complete list of test targets use the following Bazel query: `pnpm bazel query "tests(//packages/...)"` + +When debugging a specific test, change `describe()` or `it()` to `fdescribe()` +and `fit()` to focus execution to just that one test. This will keep the output clean and speed up execution by not running irrelevant tests. + +You can find more info about debugging [tests with Bazel in the docs.](https://github.com/angular/angular-cli/blob/main/docs/process/bazel.md#debugging-jasmine_node_test) + +### End to end tests + +- For a complete list of test targets use the following Bazel query: `pnpm bazel query "tests(//tests/...)"` +- Run a subset of the tests: `pnpm bazel test //tests:e2e_node22 --config=e2e --test_filter="tests/i18n/ivy-localize-*"` +- Use `bazel run` to debug failing tests debugging: `pnpm bazel run //tests:e2e_node22 --config=e2e --test_arg="--glob=tests/basic/aot.ts"` +- Provide additional `e2e_runner` options using `--test_arg`: `--test_arg="--package-manager=yarn"` + +When running the debug commands, Node will stop and wait for a debugger to attach. +You can attach your IDE to the debugger to stop on breakpoints and step through the code. Also, see [IDE Specific Usage](#ide-specific-usage) for a +simpler debug story. + +## IDE Specific Usage + +Some additional tips for developing in specific IDEs. + +### Intellij IDEA / WebStorm + +To load the project in Intellij products, simply `Open` the repository folder. +Do **not** `Import Project`, because that will overwrite the existing +configuration. + +Once opened, the editor should automatically detect run configurations in the +workspace. Use the drop down to choose which one to run and then click the `Run` +button to start it. When executing a debug target, make sure to click the +`Debug` icon to automatically attach the debugger (if you click `Run`, Node will +wait forever for a debugger to attach). + +![Intellij IDEA run configurations](images/run-configurations.png) + +### VS Code + +In order to debug some Angular CLI behaviour using Visual Studio Code, you can run `npm run build`, and then use a launch configuration like the following: + +```json +{ + "type": "node", + "request": "launch", + "name": "ng serve", + "cwd": "", + "program": "${workspaceFolder}/dist/@angular/cli/bin/ng", + "args": [ + "", + ...other arguments + ], + "console": "integratedTerminal" +} +``` + +Then you can add breakpoints in `dist/@angular` files. + +For more information about Node.js debugging in VS Code, see the related [VS Code Documentation](https://code.visualstudio.com/docs/nodejs/nodejs-debugging). + +## CPU Profiling + +In order to investigate performance issues, CPU profiling is often useful. + +### Creating a profile + +Node.js 16+ users can use the Node.js command line argument `--cpu-prof` to create a CPU profile. + +```bash +node --cpu-prof node_modules/.bin/ng build +``` + +In addition to this one, another, more elaborated way to capture a CPU profile using the Chrome Devtools is detailed in https://github.com/angular/angular-cli/issues/8259#issue-269908550. + +#### Opening a profile + +You can use the Chrome Devtools to process it. To do so: + +1. open `chrome://inspect` in Chrome +1. click on "Open dedicated DevTools for Node" +1. go to the "profiler" tab +1. click on the "Load" button and select the generated `.cpuprofile` file +1. on the left panel, select the associated file + +## Creating New Packages + +Adding a package to this repository means running two separate commands: + +1. `schematics devkit:package PACKAGE_NAME`. This will update the `.monorepo.json` file, and create the + base files for the new package (package.json, src/index, etc). +1. `pnpm admin templates`. This will update the README and all other template files that might + have changed when adding a new package. + +For private packages, you will need to add a `"private": true` key to your package.json manually. +This will require re-running the template admin script. + +## Windows + +To contribute to Angular using a Windows machine, you need to use the [Windows Linux Subsystem](https://learn.microsoft.com/en-us/windows/wsl/about) (also known as WSL). +Installing WSL on your machine requires a few extra steps, but we believe it's generally useful for developing on Windows: + +1. Run `wsl --install` from Powershell (as administrator). +2. Restart your machine. +3. Enter the WSL environment by running: `wsl`. +4. Continue with the developer guide as if you were on a native Linux system. + +For a more detailed guide, refer to the official Microsoft documentation: [Installing WSL](https://learn.microsoft.com/en-us/windows/wsl/install). + +**Note:** Angular continues to support native Windows development via the `ng` CLI and rigorously tests on Windows for every code change. This recommendation specifically applies to contributing to the Angular codebase itself. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000000..1aa9fc8f9262 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,17 @@ +# `/docs` Folder + +This folder is used for all documentation. It contains a number of subfolders with short +descriptions here. + +## `/docs/design` + +Design documents on GitHub, in Markdown format. The number of design documents available is limited. + +## `/docs/process` + +Description of various caretaker and workflow processes. + +## `/docs/specifications` + +Specifications for support of Angular CLI features. These are meant to be read directly on GitHub +by developers of libraries who want to integrate with the Angular CLI. diff --git a/docs/design/analytics.md b/docs/design/analytics.md new file mode 100644 index 000000000000..0eb3bad26d8f --- /dev/null +++ b/docs/design/analytics.md @@ -0,0 +1,101 @@ +# Usage Metrics Gathering + +This document list exactly what is gathered and how. + +Any change to analytics should most probably include a change to this document. + +## Dimensions and Metrics + +Google Analytics Custom Dimensions are used to track system values and flag values. These +dimensions are aggregated automatically on the backend. + +One dimension per flag, and although technically there can be an overlap between 2 commands, for +simplicity it should remain unique across all CLI commands. The dimension is the value of the +`x-user-analytics` field in the `schema.json` files. + +### Adding dimension or metic. +1. Create the dimension or metric in [Google Analytics](https://analytics.google.com/) first. These are not tracked if they aren't + defined in Google Analytics. +1. Use the ID of the dimension as the `x-user-analytics` value in the `schema.json` file. +1. New dimension and metrics PRs need to be approved by the tooling lead and require a new [Launch](http://go/launch). + +### Deleting a dimension or metic. +1. Archive the dimension and metric in [Google Analytics](https://analytics.google.com/). + + +**DO NOT ADD `x-user-analytics` FOR VALUES THAT ARE USER IDENTIFIABLE (PII), FOR EXAMPLE A +PROJECT NAME TO BUILD OR A MODULE NAME.** + +### Limits +| Item | Standard property limits | +|-------------------------------- |-------------------------- | +| Event-scoped custom dimensions | 50 | +| User-scoped custom dimensions | 25 | +| All custom metrics | 50 | + +### List of User Custom Dimensions + + +| Name | Parameter | Type | +|:---:|:---|:---| +| UserId | `up.ng_user_id` | `string` | +| OsArchitecture | `up.ng_os_architecture` | `string` | +| NodeVersion | `up.ng_node_version` | `string` | +| NodeMajorVersion | `upn.ng_node_major_version` | `number` | +| AngularCLIVersion | `up.ng_cli_version` | `string` | +| AngularCLIMajorVersion | `upn.ng_cli_major_version` | `number` | +| PackageManager | `up.ng_package_manager` | `string` | +| PackageManagerVersion | `up.ng_pkg_manager_version` | `string` | +| PackageManagerMajorVersion | `upn.ng_pkg_manager_major_v` | `number` | + + +### List of Event Custom Dimensions + + +| Name | Parameter | Type | +|:---:|:---|:---| +| Command | `ep.ng_command` | `string` | +| SchematicCollectionName | `ep.ng_schematic_collection_name` | `string` | +| SchematicName | `ep.ng_schematic_name` | `string` | +| Standalone | `ep.ng_standalone` | `string` | +| SSR | `ep.ng_ssr` | `string` | +| Style | `ep.ng_style` | `string` | +| Routing | `ep.ng_routing` | `string` | +| InlineTemplate | `ep.ng_inline_template` | `string` | +| InlineStyle | `ep.ng_inline_style` | `string` | +| BuilderTarget | `ep.ng_builder_target` | `string` | +| Aot | `ep.ng_aot` | `string` | +| Optimization | `ep.ng_optimization` | `string` | + + +### List of Event Custom Metrics + + +| Name | Parameter | Type | +|:---:|:---|:---| +| AllChunksCount | `epn.ng_all_chunks_count` | `number` | +| LazyChunksCount | `epn.ng_lazy_chunks_count` | `number` | +| InitialChunksCount | `epn.ng_initial_chunks_count` | `number` | +| ChangedChunksCount | `epn.ng_changed_chunks_count` | `number` | +| DurationInMs | `epn.ng_duration_ms` | `number` | +| CssSizeInBytes | `epn.ng_css_size_bytes` | `number` | +| JsSizeInBytes | `epn.ng_js_size_bytes` | `number` | +| NgComponentCount | `epn.ng_component_count` | `number` | +| AllProjectsCount | `epn.all_projects_count` | `number` | +| LibraryProjectsCount | `epn.libs_projects_count` | `number` | +| ApplicationProjectsCount | `epn.apps_projects_count` | `number` | + + +## Debugging + +Using `NG_DEBUG=1` will enable Google Analytics debug mode, To view the debug events, in Google Analytics go to `Configure > DebugView`. + +## Disabling Usage Analytics + +There are 2 ways of disabling usage analytics: + +1. using `ng analytics disable --global` (or changing the global configuration file yourself). This is the same + as answering "No" to the prompt. +1. There is an `NG_CLI_ANALYTICS` environment variable that overrides the global configuration. + That flag is a string that represents the User ID. If the string `"false"` is used it will + disable analytics for this run. diff --git a/docs/design/build-system-overview.dot b/docs/design/build-system-overview.dot new file mode 100644 index 000000000000..e792d7b42d81 --- /dev/null +++ b/docs/design/build-system-overview.dot @@ -0,0 +1,15 @@ +digraph G { + node [shape=rectangle]; + "assets array" -> "copy-webpack-plugin"; + "*.ts" -> "@ngtools/webpack" -> "@angular-devkit/build-optimizer"; + "*.js" -> "source-map-loader" -> "@angular-devkit/build-optimizer"; + "@angular-devkit/build-optimizer" -> "webpack module concatenation" -> "webpack bundling" -> "terser-webpack-plugin"; + "scripts array" -> "terser-webpack-plugin"; + "*.css" -> "postcss-loader with postcss-import, ./postcss-cli-resources.ts, autoprefixer"; + "*.scss\|sass" -> "sass-loader" -> "postcss-loader with postcss-import, ./postcss-cli-resources.ts, autoprefixer"; + "*.less" -> "less-loader" -> "postcss-loader with postcss-import, ./postcss-cli-resources.ts, autoprefixer"; + "postcss-loader with postcss-import, ./postcss-cli-resources.ts, autoprefixer" -> "raw-loader, ./optimize-css-webpack-plugin.ts" [label="component style?"]; + "raw-loader" -> "./optimize-css-webpack-plugin.ts" + "postcss-loader with postcss-import, ./postcss-cli-resources.ts, autoprefixer" -> "style-loader, ./raw-css-loader.ts, and mini-css-extract-plugin" [label="global style?"]; + "style-loader, ./raw-css-loader.ts, and mini-css-extract-plugin" -> "./optimize-css-webpack-plugin.ts" +} diff --git a/docs/design/build-system.md b/docs/design/build-system.md new file mode 100644 index 000000000000..d4d1d15b33bc --- /dev/null +++ b/docs/design/build-system.md @@ -0,0 +1,194 @@ +# Build System + +Angular CLI includes a first-party build system for Angular applications distributed as `@angular-devkit/build-angular`. +This build system is responsible for creating a standalone single-page application (SPA) from user source files and third-party dependencies. + +`@angular-devkit/build-angular` itself integrates with the rest of Angular CLI by being an [Architect builder](https://angular.dev/tools/cli/cli-builder). +This document describes a top level view of the functionality in `@angular-devkit/build-angular`, referred as just the "build system". +Deprecated or soon to be removed features are not described here. + +In broad strokes the main areas are: + +- loading and processing sources +- code splitting +- production optimizations +- post-processing steps + +Many tools are used in this process, and most of these steps happen within a [Webpack](https://webpack.js.org/) build. +We maintain a number of Webpack-centric plugins in this repository, some of these are public but most are private since they are very specific to our setup. + +## Overview diagram + +Below is a diagram of processing sources go through. +It's not strictly accurate because some options remove certain processing stages while adding others. +This diagram doesn't show these conditionals and only shows the possible processing steps. +Relative paths, such as `./raw-css-loader.ts`, refer to internal plugins, while other names usually refer to public npm packages. + +![Overview diagram](https://g.gravizo.com/source/svg?https://raw.githubusercontent.com/angular/angular-cli/main/docs/design/build-system-overview.dot) + +## Loading and processing sources + +Sources for Angular CLI browser apps are comprised of TypeScript files, style sheets, assets, scripts, and third party dependencies. +A given build will load these sources from disk, process them, and bundle them together. + +### TypeScript and Ahead-Of-Time Compilation + +Angular builds rely heavily on TypeScript-specific functionality for [Ahead-of-Time template compilation](https://angular.dev/tools/cli/aot-compiler) (AOT). +Outside Angular CLI, this is performed by the Angular Compiler (`ngc`), provided by `@angular/compiler-cli`. +To avail of Ahead-of-Time template compilation within a Webpack compilation we use and distribute the `@ngtools/webpack` Webpack plugin. + +Typescript sources are loaded from disk and compiled in-memory into JavaScript files that are stored in a virtual file system and made available to Webpack. +During compilation we also perform a number of code transformations using TypeScript transformers that enable automatic usage of AOT, internationalization features, and server-side rendering. + +AOT compilation requires loading HTML and CSS resources, referenced on Angular Components, as standalone strings with no external dependencies. +However, Webpack compilations operate on the basis of modules and references between them. +It's not possible to get the full compilation result for a given resource before the end of the compilation in webpack. +To obtain the standalone string for a HTML/CSS resource we compile it using a separate Webpack child compilation then extract the results. +These child compilations inherit configuration and access to the same files as the parent compilation, but have their own compilation life cycle and complete independently. + +The build system allows specifying replacements for specific files by replacing what path is loaded from the virtual file system. +This is used for conditional loading of code at build time. + +### Stylesheets + +Two types of stylesheets are used in the build system: global stylesheets and component stylesheets. +Global stylesheets are injected into the `index.html` file, while component stylesheets are loaded directly into compiled Angular components. + +The build system supports plain CSS stylesheets as well as the Sass and LESS CSS pre-processors. +Stylesheet processing functionality is provided by `sass-loader`, `less-loader`, `postcss-loader`, `postcss-import`, augmented in the build system by custom webpack plugins. + +### Assets + +Assets in the build system refer specifically to a list of files or directories that are meant to be copied verbatim as build artifacts. +These files are not processed and commonly include images, favicons, pdfs and other generic file types. +They are loaded into the compilation using `copy-webpack-plugin`. + +### Scripts + +Scripts in the build system refer specifically to JavaScript files that are meant to be loaded directly on `index.html` without being processed. +They are loaded into the compilation using a custom webpack plugin. + +### Third party dependencies + +Third party dependencies are mostly inside `node_modules` and are referenced via imports in source files. +Stylesheet third party dependencies are treated mostly the same as sources. + +JavaScript third party dependencies suffer a more involved process. +They are first resolved to a folder in `node_modules` via [Node Module Resolution](https://nodejs.org/api/modules.html#modules_modules). +A given module might have several different entry points, for instance one for use in NodeJS and another one for using in the browser. +Although `package.json` only officially supports listing one entry point under the `main` key, it's common for npm packages to list [other entry points](https://2ality.com/2017/04/setting-up-multi-platform-packages.html). +Each entry point is listed in under a key in that module's `package.json` whose value is a string containing a relative file path. +We use `es2015 > browser > module > main` a priority list of keys, where the first key matched name determines which entry point to use. +For instance, for a module that has both `browser` and `main` entry points, we pick `browser`. + +Once the actual JavaScript file is determined, it is loaded into the compilation together with it's source map. + +This resolution strategy supports the [Angular Package Format](https://docs.google.com/document/d/1CZC2rcpxffTDfRDs6p1cfbmKNLA6x5O-NtkJglDaBVs/edit#heading=h.k0mh3o8u5hx). + +## Code splitting + +Code is automatically split into different files (or chunks, for js files) based on a few different triggers. + +The main TypeScript entry point and it's dependencies are bundled into the `main` chunk. +Global styles and scripts get one file per entry, named after themselves. + +JavaScript code imported only via dynamic imports is automatically split into a separate chunk that is loaded asynchronously named after the file containing the dynamic import. +If multiple asynchronous chunks contain a reference to the same module, it is placed in a new asynchronously loaded chunk named after the other chunks that use it. + +There is also a special chunk called `runtime` that contains the module loading logic and is loaded before the others. + +## Optimizations + +The build system contains optimizations aimed at improving the performance (for development builds) or the size of artifacts (for production builds). +These are often mutually exclusive and thus we cannot just default to always using them. + +### Development optimizations + +Development optimizations focus on reducing rebuild time on watched builds. +Although faster is always better, our threshold is to keep rebuilds even for large projects below 2 seconds. + +Computation needed to bundle code grows with its total size because of the cost of string concatenation and source map operations. +Third party dependencies that are initially loaded are split into a synchronously loaded chunk called `vendor`. +Splitting the infrequently changed vendor code from the frequently changed source code thus helps make rebuilds faster. + +When processing stylesheets, Webpack stores the intermediate modules as JavaScript code. +The JavaScript wrapper code makes stylesheets larger and `mini-css-extract-plugin` must be used to obtain the actually stylesheet content into a CSS file. +In development however, we skip the CSS extraction and leave it as JavaScript code for faster rebuild times. + +Watched builds split the processing load of TypeScript compilation between file emission on the main process and type checking on a forked process. +Large projects can also opt-out of AOT compilation for faster rebuilds. + +### Production optimizations + +Angular CLI focuses on enabling tree-shaking (removing unused modules) and dead code elimination (removing unused module code). +These two categories have high potential for size reduction because of network effects: removing code can lead to more code being removed. + +The main tool we use to achieve this goal are the dead code elimination capabilities of [Terser](https://github.com/terser/terser). +We also use Terser's mangling, by which names, but not properties, are renamed to shorter forms. +The main characteristics of Terser to keep in mind is that it operates via static analysis and does not support the indirection introduced by module loading. +Thus the rest of the pipeline is directed towards providing Terser with code that can be removed via static analysis in large single modules scopes. + +To this end we developed [@angular-devkit/build-optimizer](https://github.com/angular/angular-cli/tree/main/packages/angular_devkit/build_optimizer), a post-processing tool for TS code. +Build Optimizer searches for code patterns produced by the TypeScript and Angular compiler that are known to inhibit dead code elimination, and converts them into equivalent structures that enable it instead (the link above contains some examples). +It also adds Terser [annotations](https://github.com/terser/terser#annotations) marking top-level functions as free from side effects for libraries that have the `sideEffects` flag set to false in `package.json`. + +Webpack itself also contains two major features that enable tree-shaking and dead code elimination: [`sideEffects` flag](https://github.com/webpack/webpack/tree/master/examples/side-effects) support and [module concatenation](https://webpack.js.org/plugins/module-concatenation-plugin/). +Having the `sideEffects` flag set to false in `package.json` of a library means that library has no top-level side-effects and only exposes imports, which allows Webpack to rewrite imports to that library directly to the modules used and not including non-imported modules at all. +Module concatenation allows Webpack to collect in a single module the content of several modules, which in turn allows Terser to more easily remove unused code since there is no module loading indirection between those modules. + +One significant pitfall of this optimization strategy is the use of code splitting. +Using code splitting is desirable in order to speed up loading of web apps by deferring code that is not necessary on the initial load. +But since code splitting necessarily makes use of module loading, it is at odds with Terser-based optimizations. + +The use of lazy loading can not only prevent further optimizations, but also regress the currently possible ones by [preventing module concatenation](https://webpack.js.org/plugins/module-concatenation-plugin/#optimization-bailouts). +Modules that were concatenated when lazy modules are not present might not be concatenated anymore after lazy loading is introduced because these modules now need to be accessed from the lazy modules and thus get their own module scope. + +Aside from tree-shaking, scripts and styles (as defined in the sources above) also undergo optimizations via [Terser](https://github.com/terser/terser) and [CleanCSS](https://github.com/jakubpawlowicz/clean-css) respectively. + +## Post-processing steps + +There are some steps that are meant to operate over whole applications and thus happen after the compilation finishes and outputs files. +The steps are described in the order in which they are executed during a build. +The execution order was determined based on the complexity of the step with a primary goal of minimizing the repetition of computationally expensive operations. + +### Differential Loading + +Differential loading is a strategy that allows your web application to support multiple browsers, but only load the necessary code that the browser needs. +When differential loading is enabled, the CLI generates two distinct variants of application bundles. + +- The first contains ES2015 syntax, takes advantage of built-in support in modern browsers, ships fewer polyfills, and results in a smaller total size. +- The second contains code in the older ES5 syntax, along with all necessary polyfills for Angular to function. This results in a larger total size, but supports older browsers. + +This process as designed has the advantage that only one full compilation of the application in ES2015 syntax is required. +This removes a large amount of otherwise unnecessary and duplicate processing such as module resolution and dead code elimination. +It also guarantees that the application is ES5 compliant including third-party code. +The two variants of application bundles are created by the following steps: + +1. A full build of the application is performed using an ES2015 output target. + The application's global stylesheets, scripts, and assets are also processed during this step via the full build. These elements are reused for both of application variants. +2. A copy of the JavaScript output application files are transformed (commonly referred to as down-leveled) to ES5 compatible syntax. + ES2015+ syntax elements such as classes are converted into functionally equivalent ES5 code structures. +3. An additional ES5-only polyfills file is generated that contains the required Angular polyfills for ES5-only browsers. +4. A single index HTML file is created that references both application variants and is designed to only load the appropriate files for each browser. + +To support loading the file sets in the appropriate browsers, the HTML `script` element's `type` and `nomodule` attributes are leveraged. +Browsers will only load a script with a known type. +The ES2015 files are referenced using a type of `module` which is only supported on browsers that support ES2015+ code. +Since browsers that do not support ES2015+ code also do not support the `module` script type, these scripts are ignored for browsers that cannot parse and execute the ES2015 code. +Browsers that support the `module` script type also support the `nomodule` attribute. +This attribute instructs a browser that supports module scripts to ignore the script with the attribute. There is one browser exception in this case: Safari 10.1. +This browser supports module scripts but does not support the `nomodule` attribute. +To support this case, a special polyfill script is included to provide a workaround for the browser. +This arrangement of script elements ensures that ES5-only browsers will only execute the ES5 script files and browsers that support ES2015+ will only execute the ES2015 script files. + +### Localization (i18n) + +The second of these post-processing steps is build-time localization. +The final js bundles are processed using `@angular/localize`, replacing any locale-specific translations. +This sort of localization produces one application for each locale, each in their own folders. + +### Service Worker + +The third and last post-processing step is the creation of a [service worker](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API). +A listing of final application files is taken, fingerprinted according to their content, and added to the service worker manifest. +This must be the last step because it needs each application file to not be modified further. diff --git a/docs/design/deployurl-basehref.md b/docs/design/deployurl-basehref.md new file mode 100644 index 000000000000..71a804c073e4 --- /dev/null +++ b/docs/design/deployurl-basehref.md @@ -0,0 +1,18 @@ +| | Deploy URL | Base HREF | +| ------------------------------------------- | :--------: | :-------: | +| Initial scripts (index.html) | ✅ 👍 | ✅ 👍 | +| Initial stylesheets (index.html) | ✅ 👍 | ✅ 👍 | +| Lazy scripts (routes/import()/etc.) | ✅ 👍 | ✅ 👍 | +| Processed CSS resources (images/fonts/etc.) | ✅ 👍 | ✅ 👍 | +| Relative template (HTML) assets | ❌ 👎 | ✅ 👍 | +| Angular Router Default Base (APP_BASE_HREF) | ❌ | ✅ \*1 | +| Single reference in deployed Application | ❌ 👎 | ✅ 👍 | +| Special resource logic within CLI | ✅ 👎 | ❌ 👍 | +| Relative fetch/XMLHttpRequest | ❌ | ✅ | + +✅ - has/affects the item/trait +❌ - does not have/affect the item/trait +👍 - favorable behavior +👎 - unfavorable behavior + +\*1 -- Users with more complicated setups may need to manually configure the `APP_BASE_HREF` token within the application. (e.g., application routing base is `/` but assets/scripts/etc. are at `/assets/`) diff --git a/docs/design/docker-deploy.md b/docs/design/docker-deploy.md deleted file mode 100644 index 256368594ab0..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`, `ng init`). -1. Don't recreate the wheel. Docker CLI tools are very full featured on their own. Implement the common Docker use cases that make it convenient for Angular applications. -1. Don't inhibit users from using the standalone Docker CLI tools for other use cases. -1. Assumes 1:1 Dockerfile with the Angular project. Support for multiple services under the same project is outside the scope of this initial design. -1. Generated starter Dockerfile will use an Nginx base image for the default server. The built ng app and Nginx configuration for HTML5 Fallback will be copied into the image. -1. User is free to modify and customize all generated files directly without involvement by the Angular CLI. -1. Image builds will support all Docker build options. -1. Container deploys will support all Docker run options. -1. Deploying to a Docker Machine can be local or remote. -1. Deploys can be made to different environments (dev, stage, prod) on the same or different Docker Machines. -1. Image pushes can be made to Docker Hub, AWS ECR, and other public/private registries. -1. Adhere to [Docker security best practices](https://docs.docker.com/engine/security/). -1. Use sensible defaults to make it easy for users to get started. -1. Support `--dry-run` and `--verbose` flags. - - -## Proposed CLI API - -### Overview - -Initialize the project for Docker builds and deploys: -``` -$ ng docker init [--environment env] -``` - -Build and push a Docker image of the Angular app to the registry: -``` -$ ng docker push [--registry url] -``` - -Deploy and run the Angular app on a Docker Machine: -``` -$ ng docker deploy [--environment env] -``` - -### Command - Docker Init - -The command `ng docker init` generates starter `Dockerfile`, `.dockerignore`, and `docker-compose.yml` files for builds and and deploys. - -Most users will start with one local 'default' Docker Machine (Mac/Win), or a local native Docker environment on Linux, where they will perform builds and run containers for development testing. Without additional arguments, this command prepares the Angular project for working with that default environment. - -**Arguments:** - -* `--environment {env}` ; initialize for a particular environment (ie. dev, stage, prod). Defaults to `"default"`. -* `--machine {machineName}` ; choose a particular Docker Machine for this environment. Defaults to the environment name. -* `--service-name {serviceName}` ; the name for the webservice that serves the angular application. Defaults to `project.name` -* `--service-port {servicePort}` ; the service port that should be mapped on the host. Defaults to `8000`. -* `--use-image` ; initializes the environment for deploying with an image, rather than performing a build. By default, this is `false` and the `docker-compose.yml` file will be initialized for builds. -* `--image-org {orgName}` ; the org name to use for image pushes. Defaults to `null`. -* `--image-name {imageName}` ; the image name to use for image pushes. Also applies when `--use-image` is `true`. Defaults to `serviceName`. -* `--image-registry {address}` ; the registry address to use for image pushes. Defaults to `registry.hub.docker.com`. Also applies when `--use-image` is `true`. - -**Example - Init default environment:** - -```bash -$ ng docker:init --image-org my-username - -Generated 'Dockerfile' -Generated '.dockerignore' -Generated 'docker-compose.yml' - -Docker is ready! - -You can build and push a Docker image of your application to a docker registry using: - $ ng docker push - -Build and run your application using: - $ ng docker deploy -``` - -**Other requirements:** - -If the Docker CLI tools are not found, display an error with instructions on how to install Docker Toolbox. If no Docker Machines are found, display an error with instructions for creating a machine. - -The command should verify that the `machineName` is valid, and be able to distinguish whether it is a remote machine or a native local Docker environment (ie. Linux, Docker Native beta). - -A notice will be displayed for any files that already exist. No existing files will be overwritten or modified. Users are free to edit and maintain the generated files. - -If an `env` name is provided, other than "default", generate compose files with the env name appended, ie. `docker-compose-${env}.yml`. - -If `--use-image == false` and the selected machine for the environment is a Docker Swarm machine, warn the user. Docker Swarm clusters cannot use the `build:` option in compose, since the resulting built image will not be distributed to other nodes. Swarm requires using the `image:` option in compose, pushing the image to a registry beforehand so that the Swarm nodes have a place to pull the image from (see [Swarm Limitations](https://docs.docker.com/compose/swarm/#building-images)). - -Certain configuration values will be stored in the project's ngConfig `angular-cli.json` for use with other docker commands (ie. deploy, logs, exec). See also: [Saved State](#saved-state) section. - -Provide instructions on what the user can do after initialization completes (push, deploy). - -Users who do not wish to push images to a registry should not be forced to. - -#### Example - `Dockerfile` blueprint - -```Dockerfile -# You are free to change the contents of this file -FROM nginx - -# Configure for angular fallback routes -COPY nginx.conf /etc/nginx/nginx.conf - -# Copy built app to wwwroot -COPY dist /usr/share/nginx/html -``` - -#### Example - `.dockerignore` blueprint - -``` -.git -.gitignore -.env* -node_modules -docker-compose*.yml -``` - -#### Example - `docker-compose.yml` blueprint - -For default case, when `--use-image == false`: - -```yaml -version: "2" -services: - ${serviceName}: - build: . - image: ${imageName} - ports: - - "${servicePort}:80" -``` - -When `--use-image == true`: - -```yaml -version: "2" -services: - ${serviceName}: - image: \${NG_APP_IMAGE} - ports: - - "${servicePort}:80" -``` - -The `\${NG_APP_IMAGE}` is a Compose variable, not an Angilar CLI var. It will be substituted during a `docker deploy` command with an environment variable of the desired tag. (See [Compose Variable Substitution](https://docs.docker.com/compose/compose-file/#variable-substitution) for more info) - -Separate `docker-compose-{environment}.yml` files are used to deploy to different [environments](https://docs.docker.com/compose/extends/#example-use-case), for use with the `ng docker deploy` command. - - -### Command - Docker Push - -The command `ng docker push` builds a Docker image of the Angular app from the `Dockerfile`, and pushes the image with a new tag to a registry address. - -Example - Build and push (with auth): -```bash -$ ng docker push --tag 1.0.0 --tag-latest -Building image... -Tagging "1.0.0"... -Tagging "latest" -Docker image built! bc2043fdd1e8 - -Pushing to registry... -> Enter your registry credentials -Username (username): -Password: - -Push complete! -my-username/my-ng-app:1.0.0 -my-username/my-ng-app:latest -``` - -#### Arguments - -* `--machine {machineName}` ; the Docker Machine to use for the build. Defaults to the "default" environment's `machineName`. -* `--image-org {orgName}` ; the org name to use for image pushes. Defaults to `null`. -* `--image-name {imageName}` ; the image name to use for image pushes. Defaults to `serviceName`. -* `--image-registry {address}` ; the registry address to use for image pushes. Defaults to `registry.hub.docker.com`. -* `--tag {tag}` ; the tag for the newly built image. Defaults to the current `package.json` "version" property value. -* `--tag-latest` ; optionally and additionally apply the "latest" tag. Defaults to `false`. -* `--no-cache` ; do not use cache when building the image. Defaults to `false`. -* `--force-rm` ; always remove intermediate containers. Defaults to `false`. -* `--pull` ; always attempt to pull a newer version of the image. Defaults to `false`. - -The `--no-cache`, `--force-rm`, and `--pull` are [compose build options](https://docs.docker.com/compose/reference/build/). - -Try an initial push. If an authentication failure occurs, attempt to login via `docker login`, or with `aws ecr get-login` (if the registry address matches `/\.dkr\.ecr\./`). Proxy the CLI input to the login commands. Avoid storing any authentication values in ngConfig. - -The `serviceName`, `registryAddress`, `orgName`, and `imageName` defaults will first be retrieved from ngConfig, which were saved during the initial `ng docker init` command. If any of these values do not exist, warn the user with instructions to add via `ng docker init` or `ng set name=value`. - -#### Internal Steps - -1. `docker-machine env {machineName}` (if remote) -1. Rebuild app for production -1. `docker-compose build {serviceName}` -1. `docker tag {imageName} {registryAddress}:{orgName}/{imageName}:{imageTag}` -1. `docker push {registryAddress}:{orgName}/{imageName}:{imageTag}` -1. `tag-latest == true` ? - 1. `docker tag {imageName} {registryAddress}:{orgName}/{imageName}:latest` - 1. `docker push {registryAddress}:{orgName}/{imageName}:latest` - - -### Command - Docker Deploy - -The command `ng docker deploy` will deploy an environment's compose configuration to a Docker Machine. It starts the containers in the background and leaves them running. - -Consider a command alias: `ng docker run`. - -Example - Default environment deploy: -```bash -$ ng docker deploy -Building... -Deploying to default environment... -Deploy complete! -``` - -Example - Deploying to a named environment, without builds: -```bash -$ ng docker:deploy --environment stage -$ ng docker:deploy --environment prod --tag 1.0.1 -``` - -Example - Deploying a specific service from the compose file: -```bash -$ ng docker deploy --services my-ng-app -``` - -#### Options - -* `--environment {env}` ; -* `--tag {tag}` ; The tag to use whe deploying to non-build Docker Machine environments; Defaults to `package.json` "version" property value. -* `--services {svc1} {svc2} ... {svcN}` ; -* `--no-cache` ; do not use cache when building the image. Defaults to `false`. -* `--force-rm` ; always remove intermediate containers. Defaults to `false`. -* `--pull` ; always attempt to pull a newer version of the image. Defaults to `false`. -* `--force-recreate` ; recreate containers even if their configuration and image haven't changed. Defaults to `false`. -* `--no-recreate` ; if containers already exist, don't recreate them. Defaults to `false`. - -The `--services` option allows for specific services to be deployed. By default, all services within the corresponding compose file will be deployed. - -The `--no-cache`, `--force-rm`, and `--pull` are [compose build options](https://docs.docker.com/compose/reference/build/). - -The `--force-recreate`, `--no-recreate` are [compose up options](https://docs.docker.com/compose/reference/up/). - -Successive deploys should only restart the updated services and not affect other existing running services. - -> If there are existing containers for a service, and the service’s configuration or image was changed after the container’s creation, docker-compose up picks up the changes by stopping and recreating the containers (preserving mounted volumes). To prevent Compose from picking up changes, use the --no-recreate flag. - -Use the `tag` value for the `${NG_APP_IMAGE_TAG}` environment variable substitution in the compose file. - -Use the `env` value to namespace the `--project-name` of the container set, for use with docker-compose when using different environment deploys on the same Docker Machine. (See also: [Compose overview](https://docs.docker.com/compose/reference/overview/)) - -#### Internal Steps - -1. `docker-machine env {machineName}` (if remote) -1. `--use-image == true` ? - 1. `export NG_APP_IMAGE={registryAddress}:{orgName}/{imageName}:{tag}` - 1. `docker-compose up -d [services]` -1. `--use-image == false` ? - 1. Rebuild app for production - 1. `docker-compose build {serviceName}` - 1. `docker-compose up -d [services]` - - -## Saved State - -Example ngConfig model for saved docker command state (per project): -``` -{ - docker: { - orgName: 'myusername', - imageName: 'ngapp', - registryAddress: 'registry.hub.docker.com', - environments: { - default: { - machineName: 'default', - isImageDeploy: false, - serviceName: 'ngapp' - }, - stage: { - machineName: 'stage', - isImageDeploy: true, - serviceName: 'ngapp' - } - } - } -} -``` - - -## Other Enhancements - -* Ability to list the configured deploy environments. -* Autocomplete environment names for `ng docker deploy`. -* New command wrappers for `docker-compose logs`, and `docker exec` commands. -* Create an Angular development environment image with everything packaged inside. Users can run the container with local volume mounts, and toolset will watch/rebuild for local file changes. Only requires Docker to be installed to get started with Angular development. There are some Node.js complexities to solve when adding new package.json dependencies, and many users report issues with file watchers and docker volume mounts. -* Deployment support to other container scheduling systems: Kubernetes, Marathon/Mesos, AWS ECS and Beanstalk, Azure Service Fabric, etc. - - -## Appendix - -### Implementation Approaches - -Two internal implementation approaches to consider when interfacing with Docker from Node.js: - -1. Docker Remote API via Node.js modules -1. Docker CLI tools via `child_process.exec` - -#### Docker Remote API - - - -> The API tends to be REST. However, for some complex commands, like attach or pull, the HTTP connection is hijacked to transport stdout, stdin and stderr. - -The API has been going through a high rate of change and has some awkward inconsistencies: - -> Build Image: reply is chunked into JSON objects like {"stream":"data"} and {"error":"problem"} instead of multiplexing stdout and stderr like the rest of the API. - -Example Image Build with `dockerode` module: - -```js -var Docker = require('dockerode'); -var tar = require('tar-fs'); - -var docker = new Docker({ - host: options.dockerHostIp, - port: options.dockerPort || 2375, - ca: fs.readFileSync(`${options.dockerCertPath}/ca.pem`), - cert: fs.readFileSync(`${options.dockerCertPath}/cert.pem`), - key: fs.readFileSync(`${options.dockerCertPath}/key.pem`) -}); -var buildImagePromise = Promise.denodeify(docker.buildImage); -var tarStream = tar.pack(project.root); - -buildImagePromise(tarStream, { - t: options.imageName -}).then((output) => { - var imageHash = output.match(/Successfully built\s+([a-f0-9]+)/m)[1]; - ui.writeLine(chalk.green(`Docker image built! ${imageHash}`)); -}); -``` - -Tradeoffs with this approach: - -* Does not require Docker CLI tools to be installed. -* Requires cert files for access to remote Docker Machines. -* Programmatic interface. -* Requires more configuration on the part of Angular CLI. -* Configuration imposes a learning curve on existing Docker users. -* No Docker-Compose API support. Multi-container management features would need to be duplicated. -* Maintenance effort to keep API updated. -* Dependent upon 3rd-party Docker module support, or creating our own. - -#### Execute Docker CLI Commands - -This method wraps the following Docker CLI tools with `exec()` calls, formatting the command arguments and parsing their output: - -* `docker-machine` : Used to initialize the `docker` client context for communication with a specific Docker Machine host, and for gathering host information (IP address). -* `docker` : The primary Docker client CLI. Good for pushing images to a registry. -* `docker-compose` : Manages multi-container deployments on a Docker Machine using a YAML format for defining container options. Can also be used to build images. - -Example image build with `docker-machine` and `docker-compose` CLI commands: - -```js -var execPromise = Promise.denodeify(require('child_process').exec); - -function getDockerEnv(machineName) { - return execPromise(`docker-machine env ${machineName}`) - .then((stdout) => { - let dockerEnv = {}; - stdout.split(/\r?\n/) - .forEach((line) => { - let m = line.match(/^export\s+(.+?)="(.*?)"$/); - if (m) dockerEnv[m[1]] = m[2]; - }); - return dockerEnv; - }); -} - -function buildImage() { - return getDockerEnv(options.buildMachineName) - .then((dockerEnv) => { - let execOptions = { - cwd: project.root, - env: dockerEnv - }; - return execPromise(`docker-compose build ${options.dockerServiceName}`, execOptions); - }) - .then((stdout) => { - var imageHash = stdout.match(/Successfully built\s+([a-f0-9]+)/m)[1]; - ui.writeLine(chalk.green(`Docker image built! ${imageHash}`)); - return imageHash - }); -} -``` - -Tradeoffs with this approach: - -* Requires user to manually install Docker CLI tools -* Must build interface around CLI commands, formatting the command arguments and parsing the output. -* Can leverage Docker-Compose and its configuration format for multi-container deploys. -* Configuration of build and deploy options is simplified in Angular CLI. -* User has the flexibility of switching between `ng` commands and Docker CLI tools without having to maintain duplicate configuration. -* Lower risk of Docker compatibility issues. User has control over their Docker version. - -##### Node.js Docker Modules - -Module | Created | Status | Dependencies ---- | --- | --- | --- -[dockerode](https://www.npmjs.com/package/dockerode) | Sep 1, 2013 | Active | 14 -[dockerizer](https://www.npmjs.com/package/dockerizer) | Feb 1, 2016 | New | 125 -[docker.io](https://www.npmjs.com/package/docker.io) | Jun 1, 2013 | Outdated | ? - -[List of Docker client libraries](https://docs.docker.com/engine/reference/api/remote_api_client_libraries/) - -#### Summary - -The "2. Docker CLI tools via `child_process.exec`" method is recommended based on the following: - -* The requirement of having the Docker CLI tools installed is not generally a problem, and they would likely already be installed by the majority of users using these features. -* Maintenance to Angular CLI would likely be easier using the Docker CLI, having less configuration, documentation, and updates than the Remote API method. -* Multi-container deploys is a common use-case. Utilizing the Docker Compose features, format, and documentation is a big win. -* Since this project is a CLI itself, using the Docker CLI tools isn't too far a leap. -* Users who do not use these features are not forced to install Docker CLI. Conversely, the Remote API method might incur a small penalty of installing unused NPM modules (ie. `dockerode`). - - -### Container Deployment APIs in the Wild - -* [Docker Run](https://docs.docker.com/engine/reference/run/) -* [Docker-Compose File](https://docs.docker.com/compose/compose-file/) -* [Kubernetes Pod](http://kubernetes.io/docs/api-reference/v1/definitions/#_v1_pod) -* [Marathon App](https://mesosphere.github.io/marathon/docs/rest-api.html#post-v2-apps) -* [Tutum Container](https://docs.tutum.co/v2/api/#container) -* [AWS Elastic Beanstalk/ECS Task Definition](http://docs.aws.amazon.com/AmazonECS/latest/developerguide/task_defintions.html) -* [Azure Service Fabric App](https://azure.microsoft.com/en-us/documentation/articles/service-fabric-deploy-remove-applications/) -* [Heroku Docker CLI](https://github.com/heroku/heroku-docker) -* [Redspread](https://github.com/redspread/spread) -* [Docker Universal Control Plane](https://www.docker.com/products/docker-universal-control-plane) -* [Puppet Docker Module](https://github.com/garethr/garethr-docker) -* [Chef Docker Cookbook](https://supermarket.chef.io/cookbooks/docker) -* [Ansible Docker Module](http://docs.ansible.com/ansible/docker_module.html) -* [Bamboo Docker Tasks](https://confluence.atlassian.com/bamboo/configuring-the-docker-task-in-bamboo-720411254.html) -* [Freight Forwarder Manifest](http://freight-forwarder.readthedocs.org/en/latest/config/overview.html) -* [Gulp Docker Tasks](https://www.npmjs.com/package/gulp-docker) -* [Grunt Dock Tasks](https://www.npmjs.com/package/grunt-dock) -* [Robo Docker Tasks](http://robo.li/tasks/Docker/) diff --git a/docs/design/ngConfig.md b/docs/design/ngConfig.md index 66f1cd53eb86..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 @@ -16,18 +16,17 @@ Since the data is static, we only need to keep it in a static store somewhere. One solution would be to keep the data in the `package.json`. Unfortunately, the metadata contains too much data and the `package.json` file would become unmanageable. -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. - +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`. +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. @@ -90,7 +93,7 @@ For example, assuming the following globals/locals: ```js // Global -{ +{ "key1": { "key2": { "value": 0, @@ -100,7 +103,7 @@ For example, assuming the following globals/locals: } // Local -{ +{ "key1": { "key2": { "value2": 2, @@ -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,8 +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 ac0a22c458e1..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 @@ -77,16 +82,19 @@ The `install` task will perform the following subtasks: 1. **Detect if a package named `angular/cli-wrapper-${libName}` exist in the angular organization.** If so, run the steps above as if ng install angular/angular-${libName}. If this install fails, ignore the failure. - + 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,13 +104,15 @@ 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. + +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. Third-parties can store data that will be passed to the app, and can use that data themselves. @@ -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/build.md b/docs/documentation/build.md deleted file mode 100644 index 2ea2c32850e5..000000000000 --- a/docs/documentation/build.md +++ /dev/null @@ -1,19 +0,0 @@ -# ng build - -## Overview -`ng build` compiles the application into an output directory - -## Options -`--target` (`-t`, `-dev`, `prod`) define the build target - -`--environment` (`-e`) - -`--output-path` (`-o`) path where output will be placed - -`--watch` (`-w`) flag to run builds when files change - -`--surpress-sizes` flag to suppress sizes from build output - -`--base-href` (`-bh`) base url for the application being built - -`--aot` flag whether to build using Ahead of Time compilation \ No newline at end of file diff --git a/docs/documentation/config.md b/docs/documentation/config.md deleted file mode 100644 index bf05ae0403e8..000000000000 --- a/docs/documentation/config.md +++ /dev/null @@ -1,2 +0,0 @@ -`ng get` -`ng set` diff --git a/docs/documentation/doc.md b/docs/documentation/doc.md deleted file mode 100644 index db47d3baa66a..000000000000 --- a/docs/documentation/doc.md +++ /dev/null @@ -1,4 +0,0 @@ -# ng doc - -## Overview -`ng doc [search term]` searches documentation on [angular.io](https://angular.io) diff --git a/docs/documentation/e2e.md b/docs/documentation/e2e.md deleted file mode 100644 index 792aae685b55..000000000000 --- a/docs/documentation/e2e.md +++ /dev/null @@ -1,4 +0,0 @@ -# ng e2e - -## Overview -`ng e2e` executes end-to-end tests diff --git a/docs/documentation/generate.md b/docs/documentation/generate.md deleted file mode 100644 index 57369efc3c45..000000000000 --- a/docs/documentation/generate.md +++ /dev/null @@ -1,14 +0,0 @@ -# ng generate - -## Overview -`ng generate [name]` generates the specified blueprint - -## Available blueprints: - - [class](class) - - [component](component) - - [directive](directive) - - [enum](enum) - - [interface](interface) - - [module](module) - - [pipe](pipe) - - [service](service) \ No newline at end of file diff --git a/docs/documentation/generate/class.md b/docs/documentation/generate/class.md deleted file mode 100644 index f5999677c7ab..000000000000 --- a/docs/documentation/generate/class.md +++ /dev/null @@ -1,7 +0,0 @@ -# ng generate class - -## Overview -`ng generate class [name]` generates a class - -## Options -`--spec` specifies if a spec file is generated \ No newline at end of file diff --git a/docs/documentation/generate/component.md b/docs/documentation/generate/component.md deleted file mode 100644 index d31c36961681..000000000000 --- a/docs/documentation/generate/component.md +++ /dev/null @@ -1,21 +0,0 @@ -# ng generate component - -## Overview -`ng generate component [name]` generates a component - -## Options -`--flat` flag to indicate if a dir is created - -`--inline-template` (`-it`) specifies if the template will be in the ts file - -`--inline-style` (`-is`) specifies if the style will be in the ts file - -`--prefix` specifies whether to use the prefix - -`--spec` specifies if a spec file is generated - -`--view-encapsulation` (`-ve`) set the view encapsulation strategy - -`--change-detection` (`-cd`) set the change detection strategy - -`--skip-import` allows for skipping the module import diff --git a/docs/documentation/generate/directive.md b/docs/documentation/generate/directive.md deleted file mode 100644 index b6250e110150..000000000000 --- a/docs/documentation/generate/directive.md +++ /dev/null @@ -1,13 +0,0 @@ -# ng generate directive - -## Overview -`ng generate directive [name]` generates a directive - -## Options -`--flat` flag to indicate if a dir is created - -`--prefix` specifies whether to use the prefix - -`--spec` specifies if a spec file is generated - -`--skip-import` allows for skipping the module import diff --git a/docs/documentation/generate/enum.md b/docs/documentation/generate/enum.md deleted file mode 100644 index 1fae1fa8a097..000000000000 --- a/docs/documentation/generate/enum.md +++ /dev/null @@ -1,4 +0,0 @@ -# ng generate enum - -## Overview -`ng generate enum [name]` generates an enumeration diff --git a/docs/documentation/generate/interface.md b/docs/documentation/generate/interface.md deleted file mode 100644 index e8129d63abfb..000000000000 --- a/docs/documentation/generate/interface.md +++ /dev/null @@ -1,8 +0,0 @@ -# ng generate interface - -## Overview -`ng generate interface [name] ` generates an interface - -## Arguments - -`type` optional string to specify the type of interface diff --git a/docs/documentation/generate/module.md b/docs/documentation/generate/module.md deleted file mode 100644 index c981b9929889..000000000000 --- a/docs/documentation/generate/module.md +++ /dev/null @@ -1,9 +0,0 @@ -# ng generate module - -## Overview -`ng generate module [name]` generates an NgModule - -## Options -`--spec` specifies if a spec file is generated - -`--routing` specifies if a routing module file should be generated diff --git a/docs/documentation/generate/pipe.md b/docs/documentation/generate/pipe.md deleted file mode 100644 index 69bce664d76c..000000000000 --- a/docs/documentation/generate/pipe.md +++ /dev/null @@ -1,11 +0,0 @@ -# ng generate pipe - -## Overview -`ng generate pipe [name]` generates a pipe - -## Options -`--flat` flag to indicate if a dir is created - -`--spec` specifies if a spec file is generated - -`--skip-import` allows for skipping the module import diff --git a/docs/documentation/generate/service.md b/docs/documentation/generate/service.md deleted file mode 100644 index cc50dde25fe8..000000000000 --- a/docs/documentation/generate/service.md +++ /dev/null @@ -1,9 +0,0 @@ -# ng generate service - -## Overview -`ng generate service [name]` generates a service - -## Options -`--flat` flag to indicate if a dir is created - -`--spec` specifies if a spec file is generated diff --git a/docs/documentation/init.md b/docs/documentation/init.md deleted file mode 100644 index 9cc8ee6346f0..000000000000 --- a/docs/documentation/init.md +++ /dev/null @@ -1,27 +0,0 @@ -# ng init - -## Overview -`ng init [name]` initializes, or re-initializes, an angular application. - -Initialization is done in-place, meaning that the generated application is initialized in the current directory. - -## Options -`--dry-run` (`-d`) run through without making any changes - -`--skip-npm` (`-sn`) skip installing npm packages - -`--skip-git` (`-sg`) skip initializing a git repository - -`--directory` (`-dir`) the directory name to create the app in - -`--source-dir` (`-sd`) the name of the source directory - -`--style` the style file default extension - -`--prefix` (`p`) the prefix to use for all component selectors - -`--routing` flag to indicate whether to generate a routing module - -`--inline-style` (`is`) flag to indicate if the app component should have an inline style - -`--inline-template` (`it`) flag to indicate if the app component should have an inline template \ No newline at end of file diff --git a/docs/documentation/new.md b/docs/documentation/new.md deleted file mode 100644 index d88018c400ad..000000000000 --- a/docs/documentation/new.md +++ /dev/null @@ -1,27 +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 -`--dry-run` (`-d`) run through without making any changes - -`--skip-npm` (`-sn`) skip installing npm packages - -`--skip-git` (`-sg`) skip initializing a git repository - -`--directory` (`-dir`) the directory name to create the app in - -`--source-dir` (`-sd`) the name of the source directory - -`--style` the style file default extension - -`--prefix` (`p`) the prefix to use for all component selectors - -`--routing` flag to indicate whether to generate a routing module - -`--inline-style` (`is`) flag to indicate if the app component should have an inline style - -`--inline-template` (`it`) flag to indicate if the app component should have an inline template \ No newline at end of file diff --git a/docs/documentation/overview.md b/docs/documentation/overview.md deleted file mode 100644 index 6286a25678a8..000000000000 --- a/docs/documentation/overview.md +++ /dev/null @@ -1,125 +0,0 @@ -# Angular CLI - -### Overview -The Angular CLI is a tool to initialize, develop, scaffold and maintain [Angular](https://angular.io) applications - -### Getting Started -To install the angular-cli: -``` -npm install -g angular-cli -``` - -Generating and serving an Angular project via a development server -[Create](new) and [run](serve) a new project: -``` -ng new my-project -cd new-project -ng serve -``` -Navigate to http://localhost:4200/. The app will automatically reload if you change any of the source files. - -### 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 -"environments": { - "source": "environments/environment.ts", - "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`. - -### Bundling - -All builds make use of bundling, and using the `--prod` flag in `ng build --prod` -or `ng serve --prod` will also make use of uglifying and tree-shaking functionality. - -### Running unit tests - -```bash -ng test -``` - -Tests will execute after a build is executed via [Karma](http://karma-runner.github.io/0.13/index.html), and it will automatically watch your files for changes. You can run tests a single time via `--watch=false` or `--single-run`. - -### Running end-to-end tests - -```bash -ng e2e -``` - -Before running the tests make sure you are serving the app via `ng serve`. -End-to-end tests are run via [Protractor](https://angular.github.io/protractor/). - -### Global styles - -The `styles.css` file allows users to add global styles and supports -[CSS imports](https://developer.mozilla.org/en/docs/Web/CSS/@import). - -If the project is created with the `--style=sass` option, this will be a `.sass` -file instead, and the same applies to `scss/less/styl`. - -You can add more global styles via the `apps[0].styles` property in `angular-cli.json`. - -### Global Library Installation - -Some javascript libraries need to be added to the global scope, and loaded as if -they were in a script tag. We can do this using the `apps[0].scripts` and -`apps[0].styles` properties of `angular-cli.json`. - -As an example, to use [Bootstrap 4](http://v4-alpha.getbootstrap.com/) this is -what you need to do: - -First install Bootstrap from `npm`: - -```bash -npm install bootstrap@next -``` - -Then add the needed script files to `apps[0].scripts`: - -```json -"scripts": [ - "../node_modules/jquery/dist/jquery.js", - "../node_modules/tether/dist/js/tether.js", - "../node_modules/bootstrap/dist/js/bootstrap.js" -], -``` - -Finally add the Bootstrap CSS to the `apps[0].styles` array: -```json -"styles": [ - "../node_modules/bootstrap/dist/css/bootstrap.css", - "styles.css" -], -``` - -Restart `ng serve` if you're running it, and Bootstrap 4 should be working on -your app. - -### Additional Commands -* [ng new](new) -* [ng init](init) -* [ng serve](serve) -* [ng generate](generate) -* [ng test](test) -* [ng e2e](e2e) -* [ng build](build) -* [ng get/ng set](config) -* [ng docs](docs) - -### How to Guides -* Setup AngularFire _(coming soon)_ -* Include bootstrap (CSS) _(coming soon)_ -* Include Font Awesome _(coming soon)_ -* Setup of global styles _(coming soon)_ -* Setup bootstrap with SASS _(coming soon)_ -* Setup Angular Material 2 _(coming soon)_ diff --git a/docs/documentation/serve.md b/docs/documentation/serve.md deleted file mode 100644 index 5dd8f5582feb..000000000000 --- a/docs/documentation/serve.md +++ /dev/null @@ -1,37 +0,0 @@ -# ng serve - -## Overview -`ng serve` builds the application and starts a web server - -## Options -`--port` (`-p`) port to serve the application on - -`--host` (`-H`) - -`--proxy-config` (`-pc`) - -`--watcher` (`-w`) provide a new watcher - -`--live-reload` (`-lr`) flag to turn off live reloading - -`--live-reload-host` (`-lrh`) specify the host for live reloading - -`--live-reload-base-url` (`-lrbu`) specify the base URL for live reloading - -`--live-reload-port` (`-lrp`) port for live reloading - -`--live-reload-live-css` flag to live reload CSS - -`--target` (`-t`, `-dev`, `-prod`) target environment - -`--environment` (`-e`) build environment - -`--ssl` flag to turn on SSL - -`--ssl-key` path to the SSL key - -`--ssl-cert` path to the SSL cert - -`--aot` flag to turn on Ahead of Time compilation - -`--open` (`-o`) opens the app in the default browser \ No newline at end of file diff --git a/docs/documentation/test.md b/docs/documentation/test.md deleted file mode 100644 index d71fce0d9982..000000000000 --- a/docs/documentation/test.md +++ /dev/null @@ -1,19 +0,0 @@ -# ng test - -## Overview -`ng test` compiles the application into an output directory - -## Options -`--watch` (`-w`) flag to run builds when files change - -`--browsers` override which browsers tests are run against - -`--colors` enable or disable colors in the output (reporters and logs) - -`--log-level` level of logging - -`--port` port where the web server will be listening - -`--reporters` list of reporters to use - -`--build` flag to build prior to running tests \ No newline at end of file diff --git a/docs/images/angular-cli-logo.png b/docs/images/angular-cli-logo.png new file mode 100644 index 000000000000..279687174851 Binary files /dev/null and b/docs/images/angular-cli-logo.png differ diff --git a/docs/images/angular-ecosystem-logos.png b/docs/images/angular-ecosystem-logos.png new file mode 100644 index 000000000000..f1f311b3d9d3 Binary files /dev/null and b/docs/images/angular-ecosystem-logos.png differ diff --git a/docs/images/run-configurations.png b/docs/images/run-configurations.png new file mode 100644 index 000000000000..b81207f163a2 Binary files /dev/null and b/docs/images/run-configurations.png differ diff --git a/docs/process/bazel.md b/docs/process/bazel.md new file mode 100644 index 000000000000..458d634ba9b6 --- /dev/null +++ b/docs/process/bazel.md @@ -0,0 +1,71 @@ +## Yarn Workspaces + +The package architecture of `angular-cli` repository is originally setup using +yarn [workspaces](https://yarnpkg.com/lang/en/docs/workspaces/). This means the +dependencies of various `package.json` in the repository are linked and +installed together. + +## Bazel + +Since then, Bazel was introduced to manage some of the build dependencies in +`angular-cli` repo. However, Bazel does **not** yet support yarn workspaces, +since it requires laying out more than one `node_modules` directory. In this +mixed mode, developers ought to take extra care to synchronize the dependencies. + +Since the `yarn_install` rule that installs all NPM dependencies in this +repository only reads from the **root** `package.json`, every Bazel target that +depends on packages downloaded from the NPM registry will need to have the +dependency declared in the **root** `package.json`. + +In addition, if the dependency is also needed at runtime (non-dev dependencies), +the dependency on the individual package's `package.json` has to be updated as +well. This is to ensure that when users download a published version from NPM, +they will be able to install all dependencies correctly without Bazel. It is the +responsibility of the developer to keep both `package.json` in sync. + +## Windows support + +In general, any sort of node file lookup on Bazel should be subject to `require.resolve`. +This is how rules_nodejs resolves paths using the Bazel runfiles mechanism, where a given +Bazel target only has access to outputs from its dependencies. + +In practice, this does not make a lot of difference on Linux. +A symlink forest is laid down where the target is going to actually run, and mostly the +files are resolved correctly whether you use `require.resolve` or not because the files are there. + +On Windows though, that's a stricter. Bazel does not lay down a symlink forest on +windows by default. If you don't use `require.resolve`, it's still possible to correctly +resolve some files, like outputs from other rules. But other files, like node modules +dependencies and data files, need to be looked up in the runfiles. + +Since the requirement is quite lax on Linux but quite strict on windows, what ends up +happening is that lack of `require.resolve` calls go unnoticed until someone tries to run +things on Windows, at which point it breaks. + +## Debugging jasmine_node_test + +On Linux, Bazel tests will run under a sandbox for isolation. +You can turn off this sandbox by adding the [`local = True`](https://docs.bazel.build/versions/master/be/common-definitions.html#common-attributes-tests) attribute to the rule. +You can also force local execution by passing `--test_output=streamed`. + +Then you will find the intermediate test files in `bazel-out/k8-fastbuild/bin`, followed by the test target path. + +Tests that are sharded, via the `shard_count` attribute, can fail if you reduce the number of tests or focus only a few. +This will cause some shards to execute 0 tests, which makes the exit with an error code. + +Tests that are marked as flaky, via the `flaky` attribute, will repeat when they fail. +This will cause any focused test to be repeated multiple time, since the presence of focused tests +causes jasmine to exit with a non-zero exit code. + +While testing, you can remove the `shard_count` attribute to prevent sharding and the `flaky` +attribute to prevent repetition. +Setting `--test_output=streamed` will disable sharding and `--flaky_test_attempts=1` will disable +the reruns of tests that have been marked as `flaky`. + +The `.bazelrc` includes a config for running tests with remote debugging enabled: + +```sh +pnpm bazel test --config=debug //packages/angular/cli:test +# Also disable reruns of failing tests that were marked as flaky: +pnpm bazel test --config=debug --config=no-sharding //packages/angular/cli:test +``` diff --git a/docs/process/release.md b/docs/process/release.md new file mode 100644 index 000000000000..5490e48e8279 --- /dev/null +++ b/docs/process/release.md @@ -0,0 +1,179 @@ +# Setting Up Local Repository + +1. Clone the Angular-CLI repo. A local copy works just fine. +1. Create an upstream remote: + +```bash +$ git remote add upstream https://github.com/angular/angular-cli.git +``` + +# Caretaker + +The caretaker should triage issues, merge PR, and sheppard the release. + +Caretaker rotation can be found +[here](https://rotations.corp.google.com/rotation/5117919353110528) and individual shifts can +be modified as necessary to accommodate caretaker's schedules. This automatically syncs to a +Google Calendar +[here](https://calendar.google.com/calendar/u/0/embed?src=c_6s96kkvd7nhink3e2gnkvfrt1g@group.calendar.google.com). +Click the "+" button in the bottom right to add it to your calendar to see shifts alongside the +rest of your schedule. + +The primary caretaker is responsible for both merging PRs and performing the weekly release. +The secondary caretaker does not have any _direct_ responsibilities, but they may need to take +over the primary's responsibilities if the primary is unavailable for an extended time (a day +or more) or in the event of an emergency. + +At the end of each caretaker's rotation, the primary should perform a handoff in which they +provide information to the next caretaker about the current state of the repository and update +the access group to now include the next caretakers. To perform this update to the access group, +the caretaker can run: + +```bash +$ pnpm ng-dev caretaker handoff +``` + +## Merging PRs + +The list of PRs which are currently ready to merge (approved with passing status checks) can +be found with [this search](https://github.com/angular/angular-cli/pulls?q=is%3Apr+is%3Aopen+label%3A%22action%3A+merge%22+-is%3Adraft). +This list should be checked daily and any ready PRs should be merged. For each PR, check the +`target` label to understand where it should be merged to. You can find which branches a specific +PR will be merged into with the `pnpm ng-dev pr check-target-branches ` command. + +When ready to merge a PR, run the following command: + +```bash +pnpm ng-dev pr merge +``` + +### Maintaining LTS branches + +Releases that are under Long Term Support (LTS) are listed on [angular.dev](https://angular.dev/reference/releases#support-policy-and-schedule). + +Since there could be more than one LTS branch at any one time, PR authors who want to +merge commits into LTS branches must open a pull request against the specific base branch they'd like to target. + +In general, cherry picks for LTS should only be done if it meets one of the criteria below: + +1. It addresses a critical security vulnerability. +2. It fixes a breaking change in the external environment. + For example, this could happen if one of the dependencies is deleted from NPM. +3. It fixes a legitimate failure on CI for a particular LTS branch. + +# Release + +Releasing is performed using Angular's unified release tooling. Each week, two releases are expected, `latest` and `next` on npm. + +**DURING a minor OR major CLI release:** + +Once FW releases the actual minor/major release (for example: `13.0.0` or `13.1.0`), update dependencies with the following: + +1. Update [`constants.bzl`](../../constants.bzl) so `@angular/core` and `ng-packagr` are using the release version (drop `-rc.*`). +2. Update all `package.json` dependencies on framework packages to to the release version (drop `-rc.*`). + - Components packages release _after_ CLI, so those should be left as `-rc.*`. +3. `pnpm install` to update the `pnpm-lock.yaml` file. + +Create a PR with the above changes ([example](https://github.com/angular/angular-cli/pull/31872)) and merge it directly to the RC +branch. This PR must land _after_ FW releases (or else CI will fail) but _before_ the CLI release PR. Releases are built before +the PR is sent for review, so any changes after that point won't be included in the release and this cannot be combined with the +release PR. + +**AFTER a minor OR major CLI release:** + +`constants.bzl` also needs to be updated to use `-next.0` after a major or minor release. However this needs to happen _after_ FW +publishes the initial `-next.0` release, which will happen 1 week after the major or minor release. + +## Releasing the CLI + +Typical patch and next releases do not require FW to release in advance, as CLI does not pin the FW +dependency. + +After confirming that the above steps have been done or are not necessary, run the following and +navigate the prompts: + +```sh +pnpm ng-dev release publish +``` + +Releases should be done in "reverse semver order", meaning they should follow: + +Oldest LTS -> Newest LTS -> Patch -> RC -> Next + +This can skip any versions which don't need releases, so most weeks are just "Patch -> Next". + +## Releasing a new package + +Wombat has some special access requirements which need to be configured to publish a new NPM package. + +See [this Wombat doc](http://g3doc/company/teams/cloud-client-libraries/team/automation/docs/npm-publish-service#existing-package) +and [this postmortem](http://docs/document/d/1emx2mhvF5xMzNUlDrVRYKI_u4iUOnVrg3rV6c5jk2is?resourcekey=0-qpsFbBfwioYT4f6kyUm8ZA&tab=t.0) +for more info. + +Angular is _not_ an organization on NPM, therefore each package is published +independently and Wombat access needs to be managed individually. This also means +we can't rely on Wombat already having access to a new package. + +In order to configure a brand new NPM package, it first needs to be published +manually so we can add Wombat access to it. Note that this step can and should be +done prior to weekly releases. The sooner this can be done, the less likely it +will block the next weekly release. + +1. Check out the `main` branch, which should always have a `-next` version. + - This avoids having the initial publish actually be used in production. +1. Trigger a release build locally. + ```shell + nvm install + pnpm install --frozen-lockfile + pnpm ng-dev release build + ``` +1. Log in to NPM as `angular`. + + ```shell + npm login + ``` + + - See these two Valentine entries for authentication details: + - https://valentine.corp.google.com/#/show/1460636514618735 + - https://valentine.corp.google.com/#/show/1531867371192103 + +1. Publish the release. + ```shell + (cd dist/releases/my-scope/my-pkg/ && npm publish --access public) + ``` +1. Add Wombat to the package. + ```shell + npm owner add google-wombot @my-scope/my-pkg + ``` +1. Don't forget to logout. + ```shell + npm logout + ``` +1. File a bug like [b/336626936](http://b/336626936) to ask Wombat maintainers to + accept the invite for the new package. + +Once Wombat accepts the invite, regular automated releases should work as expected. + +## Updating Browser Support + +Angular's browser support is defined by a [Baseline](https://web.dev/baseline) +"widely available" date. Before a new major version is released, this should be +updated to approximately the current date. + +A few weeks before a major (around feature freeze): + +1. Update `BASELINE_DATE` in + [`/constants.bzl`](/constants.bzl) to the end of the most recent month. + - For example, if it is currently May 12th, set `baselineThreshold` to April + 30th. + - Picking a date at the end of a month makes it easier to cross-reference + Angular's support with other tools (like MDN) which state Baseline support + using month specificity. + - Commit and merge the change, no other alterations or automation is + necessary in the CLI repo. +2. Update the date in the `ng-packagr` repo. + [`/.stylesheet-processor.ts`](https://github.com/ng-packagr/ng-packagr/blob/main/src/lib/styles/stylesheet-processor.ts#L25). +3. Update + [`angular.dev` documentation](https://github.com/angular/angular/tree/main/adev/src/content/reference/versions.md#browser-support) + to specify the date used and link to [browsersl.ist](https://browsersl.ist) + with the generated configuration. diff --git a/docs/specifications/schematic-collections-config.md b/docs/specifications/schematic-collections-config.md new file mode 100644 index 000000000000..5d52fb394d16 --- /dev/null +++ b/docs/specifications/schematic-collections-config.md @@ -0,0 +1,35 @@ +# Schematics Collections (`schematicCollections`) + +The `schematicCollections` can be placed under the `cli` option in the global `.angular.json` configuration, at the root or at project level in `angular.json` . + +```jsonc +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "cli": { + "schematicCollections": ["@schematics/angular", "@angular/material"] + } + // ... +} +``` + +## Rationale + +When this option is not configured and a user would like to run a schematic which is not part of `@schematics/angular`, +the collection name needs to be provided to `ng generate` command in the form of `[collection-name:schematic-name]`. This make the `ng generate` command too verbose for repeated usages. + +This is where the `schematicCollections` option can be useful. When adding `@angular/material` to the list of `schematicCollections`, the generate command will try to locate the schematic in the specified collections. + +``` +ng generate navigation +``` + +is equivalent to: + +``` +ng generate @angular/material:navigation +``` + +## Conflicting schematic names + +When multiple collections have a schematic with the same name. Both `ng generate` and `ng new` will run the first schematic matched based on the ordering (as specified) of `schematicCollections`. diff --git a/docs/specifications/schematic-prompts.md b/docs/specifications/schematic-prompts.md new file mode 100644 index 000000000000..b197cb4f5e72 --- /dev/null +++ b/docs/specifications/schematic-prompts.md @@ -0,0 +1,123 @@ +# Schematic Prompts + +Schematic prompts provide the ability to introduce user interaction into the schematic execution. The schematic runtime supports the ability to allow schematic options to be configured to display a customizable question to the user and then use the response as the value for the option. These prompts are displayed before the execution of the schematic. This allows users direct the operation of the schematic without requiring indepth knowledge of the full spectrum of options available to the user. + +To enable this capability, the JSON Schema used to define the schematic's options supports extensions to allow the declarative definition of the prompts and their respective behavior. No additional logic or changes are required to the JavaScript for a schematic to support the prompts. + +## Basic Usage + +To illustrate the addition of a prompt to an existing schematic the following example JSON schema for a hypothetical _hello world_ schematic will be used. + +```json +{ + "properties": { + "name": { + "type": "string", + "minLength": 1, + "default": "world" + }, + "useColor": { + "type": "boolean" + } + } +} +``` + +Suppose it would be preferred if the user was asked for their name. This can be accomplished by augmenting the `name` property definition with an `x-prompt` field. + +```json +"x-prompt": "What is your name?" +``` + +In most cases, only the text of the prompt is required. To minimize the amount of necessary configuration, the above _shorthand_ form is supported and will typically be all that is required. Full details regarding the _longhand_ form can be found in the **Configuration Reference** section. + +Adding a prompt to allow the user to decided whether the schematic will use color when executing its hello action is also very similar. The schema with both prompts would be as follows: + +```json +{ + "properties": { + "name": { + "type": "string", + "minLength": 1, + "default": "world", + "x-prompt": "What is your name?" + }, + "useColor": { + "type": "boolean", + "x-prompt": "Would you like the response in color?" + } + } +} +``` + +Prompts have several different types which provide the ability to display an input method that best represents the schematic option's potential values. + +- `confirmation` - A **yes** or **no** question; ideal for boolean options +- `input` - textual input; ideal for string or number options +- `list` - a predefined set of items which may be selected + +When using the _shorthand_ form, the most appropriate type will automatically be selected based on the property's schema. In the example, the `name` prompt will use an `input` type because it is a `string` property. The `useColor` prompt will use a `confirmation` type because it is a boolean property with `yes` corresponding to `true` and `no` corresponding to `false`. + +It is also important that the response from the user conforms to the constraints of the property. By specifying constraints using the JSON schema, the prompt runtime will automatically validate the response provided by the user. If the value is not acceptable, the user will be asked to enter a new value. This ensures that any values passed to the schematic will meet the expectations of the schematic's implementation and removes the need to add additional checks within the schematic's code. + +## Configuration Reference + +The `x-prompt` field supports two alternatives to enable a prompt for a schematic option. A shorthand form when additional customization is not required and a longhand form providing the ability for more control over the prompt. All user responses are validated against the property's schema. For example, string type properties can use a minimum length or regular expression constraint to control the allowed values. In the event the response fails validation, the user will be asked to enter a new value. + +### Longhand Form + +In the this form, the `x-prompt` field is an object with subfields that can be used to customize the behavior of the prompt. Note that some fields only apply to specific prompt types. + +| Field | Data Value | Default | +| --------- | ----------------------------------------- | --------------------------------- | +| `type` | `confirmation`, `input`, `list` | see shorthand section for details | +| `message` | string | N/A (required) | +| `items` | string and/or `label`/`value` object pair | only valid with type `list` | + +### Shorthand Form + +`x-prompt` [type: string] --> Question to display to the user. + +For this usage, the type of the prompt is determined by the type of the containing property. + +| Property Schema | Prompt Type | Notes | +| ------------------- | :------------: | :---------------------------------: | +| `"type": "boolean"` | `confirmation` | | +| `"type": "string"` | `input` | | +| `"type": "number"` | `input` | only valid numbers accepted | +| `"type": "integer"` | `input` | only valid numbers accepted | +| `"enum": [...]` | `list` | enum members become list selections | + +### `x-prompt` Schema + +```json +{ + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "properties": { + "type": { "type": "string" }, + "message": { "type": "string" }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "properties": { + "label": { "type": "string" }, + "value": {} + }, + "required": ["value"] + } + ] + } + } + }, + "required": ["message"] + } + ] +} +``` diff --git a/docs/specifications/universal-gotchas.md b/docs/specifications/universal-gotchas.md new file mode 100644 index 000000000000..cb7f85448547 --- /dev/null +++ b/docs/specifications/universal-gotchas.md @@ -0,0 +1,231 @@ +# Important Considerations when Using Angular Universal + +## Introduction + +Although the goal of the Universal project is the ability to seamlessly render an Angular +application on the server, there are some inconsistencies that you should consider. First, +there is the obvious discrepancy between the server and browser environments. When rendering +on the server, your application is in an ephemeral or "snapshot" state. The application is +fully rendered once, with the resulting HTML returned, and the remaining application state +destroyed until the next render. Next, the server environment inherently does not have the +same capabilities as the browser (and has some that likewise the browser does not). For +instance, the server does not have any concept of cookies. You can polyfill this and other +functionality, but there is no perfect solution for this. In later sections, we'll walk +through potential mitigations to reduce the scope of errors when rendering on the server. + +Please also note the goal of SSR: improved initial render time for your application. This +means that anything that has the potential to reduce the speed of your application in this +initial render should be avoided or sufficiently guarded against. Again, we'll review how +to accomplish this in later sections. + +## "window is not defined" + +One of the most common issues when using Angular Universal is the lack of browser global +variables in the server environment. This is because the Universal project uses +[domino](https://github.com/fgnass/domino) as the server DOM rendering engine. As a result, +there is certain functionality that won't be present or supported on the server. This +includes the `window` and `document` global objects, cookies, certain HTML elements (like canvas), +and several others. There is no exhaustive list, so please be aware of the fact that if you +see an error like this, where a previously-accessible global is not defined, it's likely because +that global is not available through domino. + +> Fun fact: Domino stands for "DOM in Node" + +### How to fix? + +#### Strategy 1: Injection + +Frequently, the needed global is available through the Angular platform via Dependency Injection (DI). +For instance, the global `document` is available through the `DOCUMENT` token. Additionally, a _very_ +primitive version of both `window` and `location` exist through the `DOCUMENT` object. For example: + +```ts +// example.service.ts +import { Injectable, Inject } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; + +@Injectable() +export class ExampleService { + constructor(@Inject(DOCUMENT) private _doc: Document) {} + + getWindow(): Window | null { + return this._doc.defaultView; + } + + getLocation(): Location { + return this._doc.location; + } + + createElement(tag: string): HTMLElement { + return this._doc.createElement(tag); + } +} +``` + +Please be judicious about using these references, and lower your expectations about their capabilities. `localStorage` +is one frequently-requested API that won't work how you want it to out of the box. If you need to write your own library +components, please consider using this method to provide similar functionality on the server (this is what Angular CDK +and Material do). + +#### Strategy 2: Guards + +If you can't inject the proper global value you need from the Angular platform, you can "guard" against +invocation of browser code, so long as you don't need to access that code on the server. For instance, +often invocations of the global `window` element are to get window size, or some other visual aspect. +However, on the server, there is no concept of "screen", and so this functionality is rarely needed. + +You may read online and elsewhere that the recommended approach is to use `isPlatformBrowser` or +`isPlatformServer`. This guidance is **incorrect**. This is because you wind up creating platform-specific +code branches in your application code. This not only increases the size of your application unnecessarily, +but it also adds complexity that then has to be maintained. By separating code into separate platform-specific +modules and implementations, your base code can remain about business logic, and platform-specific exceptions +are handled as they should be: on a case-by-case abstraction basis. This can be accomplished using Angular's Dependency +Injection (DI) in order to remove the offending code and drop in a replacement at runtime. Here's an example: + +```ts +// window-service.ts +import { Injectable } from '@angular/core'; + +@Injectable() +export class WindowService { + getWidth(): number { + return window.innerWidth; + } +} +``` + +```ts +// server-window.service.ts +import { Injectable } from '@angular/core'; +import { WindowService } from './window.service'; + +@Injectable() +export class ServerWindowService extends WindowService { + getWidth(): number { + return 0; + } +} +``` + +```ts +// app-server.module.ts +import {NgModule} from '@angular/core'; +import {WindowService} from './window.service'; +import {ServerWindowService} from './server-window.service'; + +@NgModule({ + providers: [{ + provide: WindowService, + useClass: ServerWindowService, + }] +}) +``` + +If you have a component provided by a third-party that is not Universal-compatible out of the box, +you can create two separate modules for browser and server (the server module you should already have), +in addition to your base app module. The base app module will contain all of your platform-agnostic code, +the browser module will contain all of your browser-specific/server-incompatible code, and vice-versa for +your server module. In order to avoid editing too much template code, you can create a no-op component +to drop in for the library component. Here's an example: + +```ts +// example.component.ts +import { Component } from '@angular/core'; + +@Component({ + selector: 'example-component', + template: ``, // this is provided by a third-party lib + // that causes issues rendering on Universal +}) +export class ExampleComponent {} +``` + +```ts +// app.module.ts +import {NgModule} from '@angular/core'; +import {ExampleComponent} from './example.component'; + +@NgModule({ + declarations: [ExampleComponent], +}) +``` + +```ts +// browser-app.module.ts +import {NgModule} from '@angular/core'; +import {LibraryModule} from 'some-lib'; +import {AppModule} from './app.module'; + +@NgModule({ + imports: [AppModule, LibraryModule], +}) +``` + +```ts +// library-shim.component.ts +import { Component } from '@angular/core'; + +@Component({ + selector: 'library-component', + template: '', +}) +export class LibraryShimComponent {} +``` + +```ts +// server.app.module.ts +import { NgModule } from '@angular/core'; +import { LibraryShimComponent } from './library-shim.component'; +import { AppModule } from './app.module'; + +@NgModule({ + imports: [AppModule], + declarations: [LibraryShimComponent], +}) +export class ServerAppModule {} +``` + +#### Strategy 3: Shims + +If all else fails, and you simply must have access to some sort of browser functionality, you can patch +the global scope of the server environment to include the globals you need. For instance: + +```ts +// server.ts +global['window'] = { + // properties you need implemented here... +}; +``` + +This can be applied to any undefined element. Please be careful when you do this, as playing with the global +scope is generally considered an anti-pattern. + +> Fun fact: a shim is a patch for functionality that will never be supported on a given platform. A +> polyfill is a patch for functionality that is planned to be supported, or is supported on newer versions + +## Application is slow, or worse, won't render + +The Angular Universal rendering process is straightforward, but just as simply can be blocked or slowed down +by well-meaning or innocent-looking code. First, some background on the rendering process. When a render +request is made for platform-server (the Angular Universal platform), a single route navigation is executed. +When that navigation completes, meaning that all Zone.js macrotasks are completed, the DOM in whatever state +it's in at that time is returned to the user. + +> A Zone.js macrotask is just a JavaScript macrotask that executes in/is patched by Zone.js + +This means that if there is a process, like a microtask, that takes up ticks to complete, or a long-standing +HTTP request, the rendering process will not complete, or will take longer. Macrotasks include calls to globals +like `setTimeout` and `setInterval`, and `Observables`. Calling these without cancelling them, or letting them run +longer than needed on the server could result in suboptimal rendering. + +> It may be worth brushing up on the JavaScript event loop and learning the difference between microtasks +> and macrotasks, if you don't know it already. [Here's](https://javascript.info/event-loop) a good reference. + +## My HTTP, Firebase, WebSocket, etc. won't finish before render! + +Similarly to the above section on waiting for macrotasks to complete, the flip-side is that the platform will +not wait for microtasks to complete before finishing the render. In Angular Universal, we have patched the +Angular HTTP client to turn it into a macrotask, to ensure that any needed HTTP requests complete for a given +render. However, this type of patch may not be appropriate for all microtasks, and so it is recommended you use +your best judgment on how to proceed. You can look at the code reference for how Universal wraps a task to turn +it into a macrotask, or you can simply opt to change the server behavior of the given tasks. diff --git a/docs/specifications/update.md b/docs/specifications/update.md new file mode 100644 index 000000000000..e8ec31034b85 --- /dev/null +++ b/docs/specifications/update.md @@ -0,0 +1,189 @@ +# Update Command + +`ng update` is a new command in the CLI to update one or multiple packages, its peer dependencies, and the peer dependencies that depends on it. + +If there are inconsistencies, for example if peer dependencies cannot be matches by a simple semver range, the tool will error out (and nothing will be committed on the filesystem). + +## Command Line Usage + +```bash +ng update [options] +``` + +You can specify more than one package. Each package follows the convention of `[@scope/]packageName[@version-range-or-dist-tag]`. Packages not found in your dependencies will trigger an error. Any package that has a higher version in your `package.json` will trigger an error. + +| Flag | Argument | Description | +| ---------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--force` | `boolean` | If true, skip the verification step and perform the update even if some peer dependencies would be invalidated. Peer dependencies errors will still be shown as warning. Defaults to false. | +| `--next` | `boolean` | If true, allows version discovery to include Beta and RC. Defaults to false. | +| `--migrate-only` | `boolean` | If true, don't change the `package.json` file, only apply migration scripts. | +| `--from` | `version` | Apply migrations from a certain version number. | +| `--to` | `version` | Apply migrations up to a certain version number (inclusive). By default will update to the installed version. | + +## Details + +The schematic performs the following steps, in order: + +1. Get all installed package names and versions from the `package.json` into `dependencyMap: Map`. +1. From that map, fetch all `package.json` from the NPM repository, which contains all versions, and gather them in a `Map`. +1. At the same time, update the `Map<>` with the version of the package which is believed to be installed (largest version number matching the version range). +1. **WARNING**: this might not be the exact installed versions, unfortunately. We should have a proper `package-lock.json` loader, and support `yarn.lock` as well, but these are stretch goals (and where do we stop). +1. For each packages mentioned on the command line, update to the target version (by default largest non-beta non-rc version): + +```python +# ARGV The packages being requested by the user. +# NPM A map of package name to a map of version to PackageJson structure. +# V A map of package name to available versions. +# PKG A map of package name to PackageJson structure, for the installed versions. +# next A flag for the "--next" command line argument. + +# First add all updating packages' peer dependencies. This should be recursive but simplified +# here for readability. +ARGV += [ NPM[p][max([ v for v in V[p] if (not is_beta(v) or next) ])].peerDependencies + for p in ARGV ] + +for p in ARGV: + x = max([ v for v in V[p] if (not is_beta(v) or next) ]) + + for other in set(PKG.keys()) - set([ p ]): + # Verify all packages' peer dependencies. + if has(other.peerDependencies, p) and !compatible(x, other.peerDependencies[p]): + showError('Cannot update dependency "%s": "%s" is incompatible with the updated dependency' % (x, other)) + + if any( has(other.peerDependencies, peer) and !compatible(x, other.peerDependencies[peer]) + for peer in PKG[p].peerDependencies.keys() ): + showError('Cannot update dependency "%s": "%s" depends on an incompatible peer dependency' % (x, other)) + + update_package_json(p, x) +``` + +## Library Developers + +Libraries are responsible for defining their own update schematics. The `ng update` tool will update the package.json, and if it detects the `"ng-update"` key in package.json of the library, will run the update schematic on it (with version information metadata). + +If a library does not define the `"ng-update"` key in their package.json, they are considered not supporting the update workflow and `ng update` is basically equivalent to `npm install`. + +### Migration + +In order to implement migrations in a library, the author must add the `ng-update` key to its `package.json`. This key contains the following fields: + +| Field Name | Type | Description | +| ------------------ | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `requirements` | `{ [packageName: string]: VersionRange }` | A map of package names to version to check for minimal requirement. If one of the libraries listed here does not match the version range specified in `requirements`, an error will be shown to the user to manually update those libraries. For example, `@angular/core` does not support updates from versions earlier than 5, so this field would be `{ '@angular/core': '>= 5' }`. | +| `migrations` | `string` | A relative path (or resolved using Node module resolution) to a Schematics collection definition. | +| `packageGroup` | `string[]` | A list of npm packages that are to be grouped together. When running the update schematic it will automatically include all packages as part of the packageGroup in the update (if the user also installed them). | +| `packageGroupName` | `string` | The name of the packageGroup to use. By default, uses the first package in the packageGroup. The packageGroupName needs to be part of the packageGroup and should be a valid package name. | + +#### Example given: + +Library my-lib wants to have 2 steps to update from version 4 -> 4.5 and 4.5 to 5. It would add this information in its `package.json`: + +```json +{ + "ng-update": { + "requirements": { + "my-lib": "^5" + }, + "migrations": "./migrations/migration-collection.json" + } +} +``` + +And create a migration collection (same schema as the Schematics collection): + +```json +{ + "schematics": { + "migration-01": { + "version": "6", + "factory": "./update-6" + }, + "migration-02": { + "version": "6.2", + "factory": "./update-6_2" + }, + "migration-03": { + "version": "6.3", + "factory": "./update-6_3" + }, + "migration-04": { + "version": "7", + "factory": "./update-7" + }, + "migration-05": { + "version": "8", + "factory": "./update-8" + } + } +} +``` + +The update tool would then read the current version of library installed, check against all `version` fields and run the schematics, until it reaches the version required by the user (inclusively). If such a collection is used to update from version 5 to version 7, the `01`, `02`, `03,` and `04` functions would be called. If the current version is 7 and a `--refactor-only` flag is passed, it would run the migration `04` only. More arguments are needed to know from which version you are updating. + +Running `ng update @angular/core` would be the same as `ng generate @angular/core/migrations:migration-01`. + +## Use cases + +### Help + +`ng update`, shows what updates would be applied; + +```sh +$ ng update +We analyzed your package.json, there's some packages to update: + +Name Version Command to update +---------------------------------------------------------------------------- +@angular/cli 1.7.0 > 6.0.0 ng update @angular/cli +@angular/core 5.4.3 > 6.0.1 ng update @angular/core +@angular/material 5.2.1 > 6.0.0 ng update @angular/material +@angular/router 5.4.3 > 6.0.1 ng update @angular/core + +There might be additional packages that are outdated. +``` + +### Simple Multi-steps + +I have a dependency on Angular, Material and CLI. I want to update the CLI, then Angular, then Material in separate steps. + +#### Details + +1. `ng update @angular/cli`. + Updates the CLI and packages that have a peer dependencies on the CLI (none), running refactoring tools from CLI 1 to 6. +1. `ng update @angular/core`. + Updates the Core package and all packages that have a peer dependency on it. This can get tricky if `@angular/material` get caught in the update because the version installed does not directly allow the new version of `@angular/core`. In this case + +### Complex Case + +package.json: + +```json +{ + "dependencies": { + "@angular/material": "5.0.0", + "@angular/core": "5.5.5" + } +} +``` + +Commands: + +```bash +ng update @angular/core +``` + +- updates `@angular/core` to the `latest` dist-tag (6.0.0) +- sees that `@angular/material` is not compatible with 6.0.0; **error out.** + +```bash +ng update @angular/material +``` + +- update `@angular/material` to latest version, that should be compatible with the current `@angular/core`. +- if that version is not compatible with you +- tell the user about a higher version that requires an update to `@angular/core`. + +## Notes + +1. if someone is on CLI 1.5, the command is not supported. The user needs to update to `@angular/cli@latest`, then `ng update @angular/cli`. Post install hook will check versions of cli configuration and show a message to run the `ng update` command. +1. NPM proxies or cache are not supported by the first version of this command. diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 000000000000..cc9c57c0535c --- /dev/null +++ b/eslint.config.mjs @@ -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 { fixupConfigRules, fixupPluginRules } from '@eslint/compat'; +import { FlatCompat } from '@eslint/eslintrc'; +import js from '@eslint/js'; +import stylistic from '@stylistic/eslint-plugin'; +import header from '@tony.ganchev/eslint-plugin-header'; +import typescriptEslint from '@typescript-eslint/eslint-plugin'; +import tsParser from '@typescript-eslint/parser'; +import _import from 'eslint-plugin-import'; +import globals from 'globals'; + +const compat = new FlatCompat({ + baseDirectory: import.meta.dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all, +}); + +export default [ + { + files: ['**/*.ts', '**/*.mts', '**/*.cts'], + }, + { + ignores: [ + '**/*.js', + '**/*.mjs', + '**/*.cjs', + '**/bazel-out', + '**/dist-schema', + 'goldens/public-api', + 'modules/testing/builder/projects', + 'packages/angular_devkit/build_angular/src/babel-bazel.d.ts', + 'packages/angular_devkit/build_angular/test', + 'packages/angular_devkit/build_webpack/test', + 'packages/angular_devkit/schematics_cli/blank/project-files', + 'packages/angular_devkit/schematics_cli/blank/schematic-files', + 'packages/angular_devkit/schematics_cli/schematic/files', + '**/tests', + '**/.yarn', + '**/dist', + '**/node_modules', + '**/third_party', + ], + }, + ...fixupConfigRules( + compat.extends( + 'eslint:recommended', + 'plugin:import/typescript', + 'plugin:@typescript-eslint/recommended', + 'plugin:@typescript-eslint/recommended-requiring-type-checking', + 'prettier', + ), + ), + { + plugins: { + '@stylistic': stylistic, + '@typescript-eslint': fixupPluginRules(typescriptEslint), + import: fixupPluginRules(_import), + '@tony.ganchev': header, + }, + + languageOptions: { + globals: { + ...globals.node, + }, + + parser: tsParser, + ecmaVersion: 5, + sourceType: 'module', + + parserOptions: { + projectService: true, // v8 recommended alternative to "project" + tsconfigRootDir: import.meta.dirname, + }, + }, + + linterOptions: { + // TODO: This defaults to "warn" in eslint9 and might be worth turning on. + reportUnusedDisableDirectives: 'off', + }, + + rules: { + '@stylistic/lines-around-comment': [ + 'error', + { + allowArrayStart: true, + allowBlockStart: true, + allowClassStart: true, + allowEnumStart: true, + allowInterfaceStart: true, + allowModuleStart: true, + allowObjectStart: true, + allowTypeStart: true, + beforeBlockComment: true, + ignorePattern: '@license', + }, + ], + + '@stylistic/spaced-comment': ['error', 'always'], + '@typescript-eslint/consistent-type-assertions': 'error', + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-non-null-assertion': 'error', + '@typescript-eslint/no-unnecessary-qualifier': 'error', + '@typescript-eslint/no-unused-expressions': 'error', + curly: 'error', + + '@tony.ganchev/header': [ + 'error', + 'block', + [ + '*', + ' * @license', + ' * Copyright Google LLC All Rights Reserved.', + ' *', + ' * Use of this source code is governed by an MIT-style license that can be', + ' * found in the LICENSE file at https://angular.dev/license', + ' ', + ], + 2, + ], + + 'import/first': 'error', + 'import/newline-after-import': 'error', + 'import/no-absolute-path': 'error', + 'import/no-duplicates': 'error', + 'import/order': [ + 'error', + { + alphabetize: { + order: 'asc', + }, + + groups: [['builtin', 'external'], 'parent', 'sibling', 'index'], + }, + ], + + 'max-len': [ + 'error', + { + code: 140, + ignoreUrls: true, + }, + ], + + 'max-lines-per-function': [ + 'error', + { + max: 200, + }, + ], + + 'no-caller': 'error', + 'no-console': 'error', + + 'no-empty': [ + 'error', + { + allowEmptyCatch: true, + }, + ], + + 'no-eval': 'error', + 'no-multiple-empty-lines': ['error'], + 'no-throw-literal': 'error', + + 'padding-line-between-statements': [ + 'error', + { + blankLine: 'always', + prev: '*', + next: 'return', + }, + ], + + 'sort-imports': [ + 'error', + { + ignoreDeclarationSort: true, + }, + ], + + 'spaced-comment': [ + 'error', + 'always', + { + markers: ['/'], + }, + ], + + '@typescript-eslint/await-thenable': 'off', + '@typescript-eslint/no-implied-eval': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/require-await': 'off', + '@typescript-eslint/restrict-plus-operands': 'off', + '@typescript-eslint/restrict-template-expressions': 'off', + '@typescript-eslint/unbound-method': 'off', + '@typescript-eslint/no-unsafe-enum-comparison': 'off', + '@typescript-eslint/no-redundant-type-constituents': 'off', + '@typescript-eslint/no-base-to-string': 'off', + '@typescript-eslint/no-empty-object-type': 'off', + '@typescript-eslint/no-require-imports': 'off', + '@typescript-eslint/prefer-promise-reject-errors': 'off', + '@typescript-eslint/only-throw-error': 'off', + '@typescript-eslint/no-unsafe-function-type': 'off', + '@typescript-eslint/no-unnecessary-type-assertion': 'off', + }, + }, + { + files: ['!packages/**', '**/*_spec.ts'], + + rules: { + 'max-lines-per-function': 'off', + 'no-case-declarations': 'off', + 'no-console': 'off', + }, + }, +]; 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/bootstrap-local.js b/lib/bootstrap-local.js deleted file mode 100644 index 802e55ebae0e..000000000000 --- a/lib/bootstrap-local.js +++ /dev/null @@ -1,73 +0,0 @@ -/* eslint-disable no-console */ -'use strict'; - -const fs = require('fs'); -const path = require('path'); -const ts = require('typescript'); - - -global.angularCliIsLocal = true; -global.angularCliPackages = require('./packages'); - -const compilerOptions = JSON.parse(fs.readFileSync(path.join(__dirname, '../tsconfig.json'))); - -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-cli` files anywhere though. - if (!filename.match(/angular-cli/) && 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 { - const result = ts.transpile(source, compilerOptions['compilerOptions']); - - // 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('ts-node').register({ -// project: path.dirname(__dirname), -// lazy: true -// }); - -// If we're running locally, meaning npm linked. This is basically "developer mode". -if (!__dirname.match(new RegExp(`\\${path.sep}node_modules\\${path.sep}`))) { - const packages = require('./packages'); - - // We mock the module loader so that we can fake our packages when running locally. - const Module = require('module'); - const oldLoad = Module._load; - Module._load = function (request, parent) { - if (request in packages) { - return oldLoad.call(this, packages[request].main, parent); - } else if (request.startsWith('angular-cli/')) { - // We allow deep imports (for now). - // TODO: move tests to inside angular-cli package so they don't have to deep import. - const dir = path.dirname(parent.filename); - const newRequest = path.relative(dir, path.join(__dirname, '../packages', request)); - return oldLoad.call(this, newRequest, parent); - } else { - let match = Object.keys(packages).find(pkgName => request.startsWith(pkgName + '/')); - if (match) { - const p = path.join(packages[match].root, request.substr(match.length)); - return oldLoad.call(this, p, parent); - } else { - return oldLoad.apply(this, arguments); - } - } - }; -} diff --git a/lib/packages.js b/lib/packages.js deleted file mode 100644 index 8181203af068..000000000000 --- a/lib/packages.js +++ /dev/null @@ -1,40 +0,0 @@ -'use strict'; - -const fs = require('fs'); -const glob = require('glob'); -const path = require('path'); - -const packageRoot = path.join(__dirname, '../packages'); - -// All the supported packages. Go through the packages directory and create a map of -// name => fullPath. -const packages = - glob.sync(path.join(packageRoot, '**/package.json')) - .filter(p => !p.match(/blueprints/)) - .map(pkgPath => path.relative(packageRoot, path.dirname(pkgPath))) - .map(pkgName => { - return { name: pkgName, root: path.join(packageRoot, pkgName) }; - }) - .reduce((packages, pkg) => { - let pkgJson = JSON.parse(fs.readFileSync(path.join(pkg.root, 'package.json'), 'utf8')); - let name = pkgJson['name']; - - packages[name] = { - dist: path.join(__dirname, '../dist', pkg.name), - packageJson: path.join(pkg.root, 'package.json'), - root: pkg.root, - relative: path.relative(path.dirname(__dirname), pkg.root), - main: path.resolve(pkg.root, 'src/index.ts') - }; - return packages; - }, {}); - - -module.exports = packages; - - -// If we run this from the command line, just output the list of modules neatly formatted. -if (require.main === module) { - // eslint-disable-next-line no-console - console.log(JSON.stringify(packages, null, 2)); -} 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/modules/testing/builder/projects/hello-world-app/.gitignore b/modules/testing/builder/projects/hello-world-app/.gitignore new file mode 100644 index 000000000000..e3038ca2d394 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/.gitignore @@ -0,0 +1,3 @@ +# Don't ignore node_modules, this project is not meant to be installed. +# Also, ~ import path in styles does only looks in the first node_modules found. +# /node_modules 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/packages/angular-cli/blueprints/component/files/__path__/__name__.component.__styleext__ b/modules/testing/builder/projects/hello-world-app/src/app/app.component.css similarity index 100% rename from packages/angular-cli/blueprints/component/files/__path__/__name__.component.__styleext__ 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/packages/angular-cli/blueprints/ng2/files/__path__/assets/.gitkeep b/modules/testing/builder/projects/hello-world-app/src/assets/.gitkeep similarity index 100% rename from packages/angular-cli/blueprints/ng2/files/__path__/assets/.gitkeep rename to modules/testing/builder/projects/hello-world-app/src/assets/.gitkeep diff --git a/packages/angular-cli/blueprints/ng2/files/__path__/favicon.ico b/modules/testing/builder/projects/hello-world-app/src/favicon.ico similarity index 100% rename from packages/angular-cli/blueprints/ng2/files/__path__/favicon.ico rename to modules/testing/builder/projects/hello-world-app/src/favicon.ico diff --git a/modules/testing/builder/projects/hello-world-app/src/index.html b/modules/testing/builder/projects/hello-world-app/src/index.html new file mode 100644 index 000000000000..189ff561c88c --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/src/index.html @@ -0,0 +1,14 @@ + + + + + HelloWorldApp + + + + + + + + + diff --git a/modules/testing/builder/projects/hello-world-app/src/locale/messages.xlf b/modules/testing/builder/projects/hello-world-app/src/locale/messages.xlf new file mode 100644 index 000000000000..5ba84a9b3570 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/src/locale/messages.xlf @@ -0,0 +1,18 @@ + + + + + + i18n test + + app/app.component.ts + 21 + + + app/app.component.ts + 23 + + + + + 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/modules/testing/builder/projects/hello-world-app/src/spectrum.png b/modules/testing/builder/projects/hello-world-app/src/spectrum.png new file mode 100644 index 000000000000..2a5f123afc84 Binary files /dev/null and b/modules/testing/builder/projects/hello-world-app/src/spectrum.png differ diff --git a/packages/angular-cli/blueprints/ng2/files/__path__/styles.__styleext__ b/modules/testing/builder/projects/hello-world-app/src/styles.css similarity index 100% rename from packages/angular-cli/blueprints/ng2/files/__path__/styles.__styleext__ 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/modules/testing/builder/projects/hello-world-lib/projects/lib/ng-package.json b/modules/testing/builder/projects/hello-world-lib/projects/lib/ng-package.json new file mode 100644 index 000000000000..cce46c5a8b7b --- /dev/null +++ b/modules/testing/builder/projects/hello-world-lib/projects/lib/ng-package.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../../../../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/lib", + "lib": { + "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/modules/testing/builder/projects/hello-world-lib/projects/lib/src/lib/lib.service.spec.ts b/modules/testing/builder/projects/hello-world-lib/projects/lib/src/lib/lib.service.spec.ts new file mode 100644 index 000000000000..1bc021e00abb --- /dev/null +++ b/modules/testing/builder/projects/hello-world-lib/projects/lib/src/lib/lib.service.spec.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 { TestBed, inject } from '@angular/core/testing'; + +import { LibService } from './lib.service'; + +describe('LibService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [LibService] + }); + }); + + it('should be created', inject([LibService], (service: LibService) => { + expect(service).toBeTruthy(); + })); +}); 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 17cda0eb34e8..35358deb9473 100644 --- a/package.json +++ b/package.json @@ -1,38 +1,40 @@ { - "name": "angular-cli", - "version": "1.0.0-beta.24", - "description": "CLI tool for Angular", - "main": "packages/angular-cli/lib/cli/index.js", - "trackingCode": "UA-8594346-19", - "bin": { - "ng": "./bin/ng" - }, - "keywords": [], + "name": "@angular/devkit-repo", + "version": "22.3.0-next.4", + "private": true, + "description": "Software Development Kit for Angular", + "keywords": [ + "angular", + "Angular CLI", + "devkit", + "sdk", + "Angular DevKit" + ], "scripts": { - "build": "node ./scripts/publish/build.js", - "build:patch": "node ./scripts/patch.js", - "build:packages": "for PKG in packages/*; do echo Building $PKG...; tsc -p $PKG; done", - "test": "npm-run-all -c test:packages test:cli test:deps", - "e2e": "npm run test:e2e", - "e2e:nightly": "node tests/e2e_runner.js --nightly", - "mobile_test": "mocha tests/e2e/e2e_workflow.spec.js", - "test:e2e": "node tests/e2e_runner.js", - "test:cli": "node tests/runner", - "test:deps": "node scripts/publish/validate_dependencies.js", - "test:inspect": "node --inspect --debug-brk tests/runner", - "test:packages": "node scripts/run-packages-spec.js", - "build-config-interface": "dtsgen packages/angular-cli/lib/config/schema.json --out packages/angular-cli/lib/config/schema.d.ts", - "eslint": "eslint .", - "tslint": "tslint \"**/*.ts\" -c tslint.json -e \"**/blueprints/*/files/**/*.ts\" -e \"node_modules/**\" -e \"tmp/**\" -e \"dist/**\"", - "lint": "npm-run-all -c eslint tslint" + "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": ">= 4.1.0", - "npm": ">= 3.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", @@ -41,135 +43,101 @@ }, "homepage": "https://github.com/angular/angular-cli", "dependencies": { - "@angular-cli/ast-tools": "^1.0.0", - "@angular/compiler": "^2.3.1", - "@angular/compiler-cli": "^2.3.1", - "@angular/core": "^2.3.1", - "@angular/tsc-wrapped": "^0.5.0", - "async": "^2.1.4", - "autoprefixer": "^6.5.3", - "chalk": "^1.1.3", - "common-tags": "^1.3.1", - "configstore": "^2.0.0", - "css-loader": "^0.23.1", - "debug": "^2.1.3", - "denodeify": "^1.2.1", - "diff": "^2.2.2", - "ember-cli-normalize-entity-name": "^1.0.0", - "ember-cli-preprocess-registry": "^2.0.0", - "ember-cli-string-utils": "^1.0.0", - "enhanced-resolve": "^2.3.0", - "exists-sync": "0.0.3", - "extract-text-webpack-plugin": "^2.0.0-beta.4", - "file-loader": "^0.8.5", - "findup": "0.1.5", - "fs-extra": "^0.30.0", - "get-caller-file": "^1.0.0", - "git-repo-info": "^1.0.4", - "glob": "^7.0.3", - "html-webpack-plugin": "^2.19.0", - "inflection": "^1.7.0", - "inquirer": "^0.12.0", - "is-git-url": "^0.2.0", - "isbinaryfile": "^2.0.3", - "json-loader": "^0.5.4", - "karma-sourcemap-loader": "^0.3.7", - "karma-webpack": "^1.8.0", - "less": "^2.7.1", - "less-loader": "^2.2.3", - "loader-utils": "^0.2.16", - "lodash": "^4.11.1", - "magic-string": "^0.16.0", - "markdown-it": "4.3.0", - "markdown-it-terminal": "0.0.3", - "minimatch": "^3.0.3", - "node-modules-path": "^1.0.0", - "node-sass": "^3.10.1", - "nopt": "^3.0.1", - "offline-plugin": "^3.4.1", - "opn": "4.0.1", - "ora": "^0.2.0", - "portfinder": "1.0.9", - "postcss-discard-comments": "^2.0.4", - "postcss-loader": "^0.9.1", - "quick-temp": "0.1.5", - "raw-loader": "^0.5.1", - "readline2": "0.1.1", - "reflect-metadata": "^0.1.8", - "remap-istanbul": "^0.6.4", - "resolve": "^1.1.7", - "rimraf": "^2.5.3", - "rsvp": "^3.0.17", - "rxjs": "^5.0.1", - "sass-loader": "^4.0.1", - "script-loader": "^0.7.0", - "semver": "^5.1.0", - "silent-error": "^1.0.0", - "source-map": "^0.5.6", - "source-map-loader": "^0.1.5", - "sourcemap-istanbul-instrumenter-loader": "^0.2.0", - "style-loader": "^0.13.1", - "stylus": "^0.54.5", - "stylus-loader": "^2.1.0", - "temp": "0.8.3", - "through": "^2.3.6", - "tslint": "^4.0.2", - "tslint-loader": "^3.3.0", - "typescript": "~2.0.3", - "url-loader": "^0.5.7", - "uuid": "^3.0.0", - "walk-sync": "^0.2.6", - "webpack": "2.1.0-beta.25", - "webpack-dev-server": "2.1.0-beta.9", - "webpack-merge": "^0.14.0", - "webpack-sources": "^0.1.3", - "yam": "0.0.18", - "zone.js": "^0.7.2" - }, - "ember-addon": { - "paths": [ - "./packages/angular-cli/lib/addon" - ] + "@angular/compiler-cli": "22.3.0-next.3", + "typescript": "6.0.3" }, "devDependencies": { - "@types/chai": "^3.4.32", - "@types/chalk": "^0.4.28", - "@types/common-tags": "^1.2.4", - "@types/denodeify": "^1.2.29", - "@types/express": "^4.0.32", - "@types/fs-extra": "^0.0.31", - "@types/glob": "^5.0.29", - "@types/jasmine": "^2.2.32", - "@types/lodash": "^4.14.43", - "@types/mock-fs": "3.6.28", - "@types/node": "^6.0.36", - "@types/request": "0.0.30", - "@types/rimraf": "0.0.25-alpha", - "@types/source-map": "^0.5.0", - "@types/webpack": "^1.12.22-alpha", - "chai": "^3.5.0", - "conventional-changelog": "^1.1.0", - "dtsgenerator": "^0.7.1", - "eslint": "^2.8.0", - "exists-sync": "0.0.3", - "express": "^4.14.0", - "jasmine": "^2.4.1", - "jasmine-spec-reporter": "^2.7.0", - "minimatch": "^3.0.3", - "minimist": "^1.2.0", - "mocha": "^2.4.5", - "mock-fs": "3.10.0", - "npm-run": "^4.1.0", - "npm-run-all": "^3.0.0", - "object-assign": "^4.0.1", - "request": "^2.74.0", - "resolve-bin": "^0.4.0", - "rewire": "^2.5.1", - "sinon": "^1.17.3", - "through": "^2.3.8", - "tree-kill": "^1.0.0", - "ts-node": "^1.3.0", - "tslint": "^4.0.2", - "walk-sync": "^0.2.6" + "@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#37be0b2012ab2d2f58d575689b1bac74eed56e41", + "@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/@angular-cli/ast-tools/package.json b/packages/@angular-cli/ast-tools/package.json deleted file mode 100644 index f065befb69d9..000000000000 --- a/packages/@angular-cli/ast-tools/package.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "@angular-cli/ast-tools", - "version": "1.0.11", - "description": "CLI tool for Angular", - "main": "./src/index.js", - "keywords": [ - "angular", - "cli", - "ast", - "tool" - ], - "repository": { - "type": "git", - "url": "https://github.com/angular/angular-cli.git" - }, - "author": "angular", - "license": "MIT", - "bugs": { - "url": "https://github.com/angular/angular-cli/issues" - }, - "homepage": "https://github.com/angular/angular-cli", - "engines": { - "node": ">= 4.1.0", - "npm": ">= 3.0.0" - }, - "dependencies": { - "@angular/tsc-wrapped": "^0.5.0", - "rxjs": "^5.0.1", - "denodeify": "^1.2.1", - "typescript": "~2.0.3" - } -} diff --git a/packages/@angular-cli/ast-tools/src/ast-utils.spec.ts b/packages/@angular-cli/ast-tools/src/ast-utils.spec.ts deleted file mode 100644 index 17c0444e417b..000000000000 --- a/packages/@angular-cli/ast-tools/src/ast-utils.spec.ts +++ /dev/null @@ -1,300 +0,0 @@ -import denodeify = require('denodeify'); -import mockFs = require('mock-fs'); -import ts = require('typescript'); -import fs = require('fs'); - -import {InsertChange, NodeHost, RemoveChange} from './change'; -import {insertAfterLastOccurrence, addDeclarationToModule} from './ast-utils'; -import {findNodes} from './node'; -import {it} from './spec-utils'; - -const readFile = denodeify(fs.readFile); - - -describe('ast-utils: findNodes', () => { - const sourceFile = 'tmp/tmp.ts'; - - beforeEach(() => { - let mockDrive = { - 'tmp': { - 'tmp.ts': `import * as myTest from 'tests' \n` + - 'hello.' - } - }; - mockFs(mockDrive); - }); - - afterEach(() => { - mockFs.restore(); - }); - - it('finds no imports', () => { - let editedFile = new RemoveChange(sourceFile, 0, `import * as myTest from 'tests' \n`); - return editedFile - .apply(NodeHost) - .then(() => { - let rootNode = getRootNode(sourceFile); - let nodes = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration); - expect(nodes).toEqual([]); - }); - }); - it('finds one import', () => { - let rootNode = getRootNode(sourceFile); - let nodes = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration); - expect(nodes.length).toEqual(1); - }); - it('finds two imports from inline declarations', () => { - // remove new line and add an inline import - let editedFile = new RemoveChange(sourceFile, 32, '\n'); - return editedFile - .apply(NodeHost) - .then(() => { - let insert = new InsertChange(sourceFile, 32, `import {Routes} from '@angular/routes'`); - return insert.apply(NodeHost); - }) - .then(() => { - let rootNode = getRootNode(sourceFile); - let nodes = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration); - expect(nodes.length).toEqual(2); - }); - }); - it('finds two imports from new line separated declarations', () => { - let editedFile = new InsertChange(sourceFile, 33, `import {Routes} from '@angular/routes'`); - return editedFile - .apply(NodeHost) - .then(() => { - let rootNode = getRootNode(sourceFile); - let nodes = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration); - expect(nodes.length).toEqual(2); - }); - }); -}); - -describe('ast-utils: insertAfterLastOccurrence', () => { - const sourceFile = 'tmp/tmp.ts'; - beforeEach(() => { - let mockDrive = { - 'tmp': { - 'tmp.ts': '' - } - }; - mockFs(mockDrive); - }); - - afterEach(() => { - mockFs.restore(); - }); - - it('inserts at beginning of file', () => { - let imports = getNodesOfKind(ts.SyntaxKind.ImportDeclaration, sourceFile); - return insertAfterLastOccurrence(imports, `\nimport { Router } from '@angular/router';`, - sourceFile, 0) - .apply(NodeHost) - .then(() => { - return readFile(sourceFile, 'utf8'); - }).then((content) => { - let expected = '\nimport { Router } from \'@angular/router\';'; - expect(content).toEqual(expected); - }); - }); - it('throws an error if first occurence with no fallback position', () => { - let imports = getNodesOfKind(ts.SyntaxKind.ImportDeclaration, sourceFile); - expect(() => insertAfterLastOccurrence(imports, `import { Router } from '@angular/router';`, - sourceFile)).toThrowError(); - }); - it('inserts after last import', () => { - let content = `import { foo, bar } from 'fizz';`; - let editedFile = new InsertChange(sourceFile, 0, content); - return editedFile - .apply(NodeHost) - .then(() => { - let imports = getNodesOfKind(ts.SyntaxKind.ImportDeclaration, sourceFile); - return insertAfterLastOccurrence(imports, ', baz', sourceFile, - 0, ts.SyntaxKind.Identifier) - .apply(NodeHost); - }) - .then(() => { - return readFile(sourceFile, 'utf8'); - }) - .then(newContent => expect(newContent).toEqual(`import { foo, bar, baz } from 'fizz';`)); - }); - it('inserts after last import declaration', () => { - let content = `import * from 'foo' \n import { bar } from 'baz'`; - let editedFile = new InsertChange(sourceFile, 0, content); - return editedFile - .apply(NodeHost) - .then(() => { - let imports = getNodesOfKind(ts.SyntaxKind.ImportDeclaration, sourceFile); - return insertAfterLastOccurrence(imports, `\nimport Router from '@angular/router'`, - sourceFile) - .apply(NodeHost); - }) - .then(() => { - return readFile(sourceFile, 'utf8'); - }) - .then(newContent => { - let expected = `import * from 'foo' \n import { bar } from 'baz'` + - `\nimport Router from '@angular/router'`; - expect(newContent).toEqual(expected); - }); - }); - it('inserts correctly if no imports', () => { - let content = `import {} from 'foo'`; - let editedFile = new InsertChange(sourceFile, 0, content); - return editedFile - .apply(NodeHost) - .then(() => { - let imports = getNodesOfKind(ts.SyntaxKind.ImportDeclaration, sourceFile); - return insertAfterLastOccurrence(imports, ', bar', sourceFile, undefined, - ts.SyntaxKind.Identifier) - .apply(NodeHost); - }) - .catch(() => { - return readFile(sourceFile, 'utf8'); - }) - .then(newContent => { - expect(newContent).toEqual(content); - // use a fallback position for safety - let imports = getNodesOfKind(ts.SyntaxKind.ImportDeclaration, sourceFile); - let pos = findNodes(imports.sort((a, b) => a.pos - b.pos).pop(), - ts.SyntaxKind.CloseBraceToken).pop().pos; - return insertAfterLastOccurrence(imports, ' bar ', - sourceFile, pos, ts.SyntaxKind.Identifier) - .apply(NodeHost); - }) - .then(() => { - return readFile(sourceFile, 'utf8'); - }) - .then(newContent => { - expect(newContent).toEqual(`import { bar } from 'foo'`); - }); - }); -}); - - -describe('addDeclarationToModule', () => { - beforeEach(() => { - mockFs({ - '1.ts': ` -import {NgModule} from '@angular/core'; - -@NgModule({ - declarations: [] -}) -class Module {}`, - '2.ts': ` -import {NgModule} from '@angular/core'; - -@NgModule({ - declarations: [ - Other - ] -}) -class Module {}`, - '3.ts': ` -import {NgModule} from '@angular/core'; - -@NgModule({ -}) -class Module {}`, - '4.ts': ` -import {NgModule} from '@angular/core'; - -@NgModule({ - field1: [], - field2: {} -}) -class Module {}` - }); - }); - afterEach(() => mockFs.restore()); - - it('works with empty array', () => { - return addDeclarationToModule('1.ts', 'MyClass', 'MyImportPath') - .then(change => change.apply(NodeHost)) - .then(() => readFile('1.ts', 'utf-8')) - .then(content => { - expect(content).toEqual( - '\n' + - 'import {NgModule} from \'@angular/core\';\n' + - 'import { MyClass } from \'MyImportPath\';\n' + - '\n' + - '@NgModule({\n' + - ' declarations: [MyClass]\n' + - '})\n' + - 'class Module {}' - ); - }); - }); - - it('works with array with declarations', () => { - return addDeclarationToModule('2.ts', 'MyClass', 'MyImportPath') - .then(change => change.apply(NodeHost)) - .then(() => readFile('2.ts', 'utf-8')) - .then(content => { - expect(content).toEqual( - '\n' + - 'import {NgModule} from \'@angular/core\';\n' + - 'import { MyClass } from \'MyImportPath\';\n' + - '\n' + - '@NgModule({\n' + - ' declarations: [\n' + - ' Other,\n' + - ' MyClass\n' + - ' ]\n' + - '})\n' + - 'class Module {}' - ); - }); - }); - - it('works without any declarations', () => { - return addDeclarationToModule('3.ts', 'MyClass', 'MyImportPath') - .then(change => change.apply(NodeHost)) - .then(() => readFile('3.ts', 'utf-8')) - .then(content => { - expect(content).toEqual( - '\n' + - 'import {NgModule} from \'@angular/core\';\n' + - 'import { MyClass } from \'MyImportPath\';\n' + - '\n' + - '@NgModule({\n' + - ' declarations: [MyClass]\n' + - '})\n' + - 'class Module {}' - ); - }); - }); - - it('works without a declaration field', () => { - return addDeclarationToModule('4.ts', 'MyClass', 'MyImportPath') - .then(change => change.apply(NodeHost)) - .then(() => readFile('4.ts', 'utf-8')) - .then(content => { - expect(content).toEqual( - '\n' + - 'import {NgModule} from \'@angular/core\';\n' + - 'import { MyClass } from \'MyImportPath\';\n' + - '\n' + - '@NgModule({\n' + - ' field1: [],\n' + - ' field2: {},\n' + - ' declarations: [MyClass]\n' + - '})\n' + - 'class Module {}' - ); - }); - }); -}); - -/** - * Gets node of kind kind from sourceFile - */ -function getNodesOfKind(kind: ts.SyntaxKind, sourceFile: string) { - return findNodes(getRootNode(sourceFile), kind); -} - -function getRootNode(sourceFile: string) { - return ts.createSourceFile(sourceFile, fs.readFileSync(sourceFile).toString(), - ts.ScriptTarget.ES6, true); -} diff --git a/packages/@angular-cli/ast-tools/src/ast-utils.ts b/packages/@angular-cli/ast-tools/src/ast-utils.ts deleted file mode 100644 index 8d0980102116..000000000000 --- a/packages/@angular-cli/ast-tools/src/ast-utils.ts +++ /dev/null @@ -1,279 +0,0 @@ -import * as ts from 'typescript'; -import * as fs from 'fs'; -import {Symbols} from '@angular/tsc-wrapped/src/symbols'; -import { - isMetadataImportedSymbolReferenceExpression, - isMetadataModuleReferenceExpression -} from '@angular/tsc-wrapped'; -import {Change, InsertChange, NoopChange, MultiChange} from './change'; -import {findNodes} from './node'; -import {insertImport} from './route-utils'; - -import {Observable} from 'rxjs/Observable'; -import {ReplaySubject} from 'rxjs/ReplaySubject'; -import 'rxjs/add/observable/empty'; -import 'rxjs/add/observable/of'; -import 'rxjs/add/operator/do'; -import 'rxjs/add/operator/filter'; -import 'rxjs/add/operator/last'; -import 'rxjs/add/operator/map'; -import 'rxjs/add/operator/mergeMap'; -import 'rxjs/add/operator/toArray'; -import 'rxjs/add/operator/toPromise'; - - -/** -* Get TS source file based on path. -* @param filePath -* @return source file of ts.SourceFile kind -*/ -export function getSource(filePath: string): ts.SourceFile { - return ts.createSourceFile(filePath, fs.readFileSync(filePath).toString(), - ts.ScriptTarget.ES6, true); -} - - -/** - * Get all the nodes from a source, as an observable. - * @param sourceFile The source file object. - * @returns {Observable} An observable of all the nodes in the source. - */ -export function getSourceNodes(sourceFile: ts.SourceFile): Observable { - const subject = new ReplaySubject(); - let nodes: ts.Node[] = [sourceFile]; - - while (nodes.length > 0) { - const node = nodes.shift(); - - if (node) { - subject.next(node); - if (node.getChildCount(sourceFile) >= 0) { - nodes.unshift(...node.getChildren()); - } - } - } - - subject.complete(); - return subject.asObservable(); -} - - -/** - * Helper for sorting nodes. - * @return function to sort nodes in increasing order of position in sourceFile - */ -function nodesByPosition(first: ts.Node, second: ts.Node): number { - return first.pos - second.pos; -} - - -/** - * Insert `toInsert` after the last occurence of `ts.SyntaxKind[nodes[i].kind]` - * or after the last of occurence of `syntaxKind` if the last occurence is a sub child - * of ts.SyntaxKind[nodes[i].kind] and save the changes in file. - * - * @param nodes insert after the last occurence of nodes - * @param toInsert string to insert - * @param file file to insert changes into - * @param fallbackPos position to insert if toInsert happens to be the first occurence - * @param syntaxKind the ts.SyntaxKind of the subchildren to insert after - * @return Change instance - * @throw Error if toInsert is first occurence but fall back is not set - */ -export function insertAfterLastOccurrence(nodes: ts.Node[], toInsert: string, - file: string, fallbackPos?: number, syntaxKind?: ts.SyntaxKind): Change { - let lastItem = nodes.sort(nodesByPosition).pop(); - if (syntaxKind) { - lastItem = findNodes(lastItem, syntaxKind).sort(nodesByPosition).pop(); - } - if (!lastItem && fallbackPos == undefined) { - throw new Error(`tried to insert ${toInsert} as first occurence with no fallback position`); - } - let lastItemPosition: number = lastItem ? lastItem.end : fallbackPos; - return new InsertChange(file, lastItemPosition, toInsert); -} - - -export function getContentOfKeyLiteral(source: ts.SourceFile, node: ts.Node): string { - if (node.kind == ts.SyntaxKind.Identifier) { - return (node as ts.Identifier).text; - } else if (node.kind == ts.SyntaxKind.StringLiteral) { - return (node as ts.StringLiteral).text; - } else { - return null; - } -} - - -export function getDecoratorMetadata(source: ts.SourceFile, identifier: string, - module: string): Observable { - const symbols = new Symbols(source as any); - - return getSourceNodes(source) - .filter(node => { - return node.kind == ts.SyntaxKind.Decorator - && (node).expression.kind == ts.SyntaxKind.CallExpression; - }) - .map(node => (node).expression) - .filter(expr => { - if (expr.expression.kind == ts.SyntaxKind.Identifier) { - const id = expr.expression; - const metaData = symbols.resolve(id.getFullText(source)); - if (isMetadataImportedSymbolReferenceExpression(metaData)) { - return metaData.name == identifier && metaData.module == module; - } - } else if (expr.expression.kind == ts.SyntaxKind.PropertyAccessExpression) { - // This covers foo.NgModule when importing * as foo. - const paExpr = expr.expression; - // If the left expression is not an identifier, just give up at that point. - if (paExpr.expression.kind !== ts.SyntaxKind.Identifier) { - return false; - } - - const id = paExpr.name; - const moduleId = paExpr.expression; - const moduleMetaData = symbols.resolve(moduleId.getFullText(source)); - if (isMetadataModuleReferenceExpression(moduleMetaData)) { - return moduleMetaData.module == module && id.getFullText(source) == identifier; - } - } - return false; - }) - .filter(expr => expr.arguments[0] - && expr.arguments[0].kind == ts.SyntaxKind.ObjectLiteralExpression) - .map(expr => expr.arguments[0]); -} - - -function _addSymbolToNgModuleMetadata(ngModulePath: string, metadataField: string, - symbolName: string, importPath: string) { - const source: ts.SourceFile = getSource(ngModulePath); - let metadata = getDecoratorMetadata(source, 'NgModule', '@angular/core'); - - // Find the decorator declaration. - return metadata - .toPromise() - .then((node: ts.ObjectLiteralExpression) => { - if (!node) { - return null; - } - - // Get all the children property assignment of object literals. - return node.properties - .filter(prop => prop.kind == ts.SyntaxKind.PropertyAssignment) - // Filter out every fields that's not "metadataField". Also handles string literals - // (but not expressions). - .filter((prop: ts.PropertyAssignment) => { - const name = prop.name; - switch (name.kind) { - case ts.SyntaxKind.Identifier: - return (name as ts.Identifier).getText(source) == metadataField; - case ts.SyntaxKind.StringLiteral: - return (name as ts.StringLiteral).text == metadataField; - } - - return false; - }); - }) - // Get the last node of the array literal. - .then((matchingProperties: ts.ObjectLiteralElement[]): any => { - if (!matchingProperties) { - return null; - } - if (matchingProperties.length == 0) { - return metadata.toPromise(); - } - - const assignment = matchingProperties[0]; - - // If it's not an array, nothing we can do really. - if (assignment.initializer.kind !== ts.SyntaxKind.ArrayLiteralExpression) { - return null; - } - - const arrLiteral = assignment.initializer; - if (arrLiteral.elements.length == 0) { - // Forward the property. - return arrLiteral; - } - return arrLiteral.elements; - }) - .then((node: ts.Node) => { - if (!node) { - /* eslint-disable no-console */ - console.log('No app module found. Please add your new class to your component.'); - return new NoopChange(); - } - if (Array.isArray(node)) { - node = node[node.length - 1]; - } - - let toInsert: string; - let position = node.getEnd(); - if (node.kind == ts.SyntaxKind.ObjectLiteralExpression) { - // We haven't found the field in the metadata declaration. Insert a new - // field. - let expr = node; - if (expr.properties.length == 0) { - position = expr.getEnd() - 1; - toInsert = ` ${metadataField}: [${symbolName}]\n`; - } else { - node = expr.properties[expr.properties.length - 1]; - position = node.getEnd(); - // Get the indentation of the last element, if any. - const text = node.getFullText(source); - if (text.match('^\r?\r?\n')) { - toInsert = `,${text.match(/^\r?\n\s+/)[0]}${metadataField}: [${symbolName}]`; - } else { - toInsert = `, ${metadataField}: [${symbolName}]`; - } - } - } else if (node.kind == ts.SyntaxKind.ArrayLiteralExpression) { - // We found the field but it's empty. Insert it just before the `]`. - position--; - toInsert = `${symbolName}`; - } else { - // Get the indentation of the last element, if any. - const text = node.getFullText(source); - if (text.match(/^\r?\n/)) { - toInsert = `,${text.match(/^\r?\n(\r?)\s+/)[0]}${symbolName}`; - } else { - toInsert = `, ${symbolName}`; - } - } - - const insert = new InsertChange(ngModulePath, position, toInsert); - const importInsert: Change = insertImport( - ngModulePath, symbolName.replace(/\..*$/, ''), importPath); - return new MultiChange([insert, importInsert]); - }); -} - -/** -* Custom function to insert a declaration (component, pipe, directive) -* into NgModule declarations. It also imports the component. -*/ -export function addDeclarationToModule(modulePath: string, classifiedName: string, - importPath: string): Promise { - - return _addSymbolToNgModuleMetadata(modulePath, 'declarations', classifiedName, importPath); -} - -/** - * Custom function to insert a declaration (component, pipe, directive) - * into NgModule declarations. It also imports the component. - */ -export function addImportToModule(modulePath: string, classifiedName: string, - importPath: string): Promise { - - return _addSymbolToNgModuleMetadata(modulePath, 'imports', classifiedName, importPath); -} - -/** - * Custom function to insert a provider into NgModule. It also imports it. - */ -export function addProviderToModule(modulePath: string, classifiedName: string, - importPath: string): Promise { - return _addSymbolToNgModuleMetadata(modulePath, 'providers', classifiedName, importPath); -} - diff --git a/packages/@angular-cli/ast-tools/src/change.spec.ts b/packages/@angular-cli/ast-tools/src/change.spec.ts deleted file mode 100644 index 1a3cc1833bcf..000000000000 --- a/packages/@angular-cli/ast-tools/src/change.spec.ts +++ /dev/null @@ -1,131 +0,0 @@ -'use strict'; - -// This needs to be first so fs module can be mocked correctly. -let mockFs = require('mock-fs'); - -import {it} from './spec-utils'; -import {InsertChange, NodeHost, RemoveChange, ReplaceChange} from './change'; -import fs = require('fs'); - -let path = require('path'); -let Promise = require('angular-cli/ember-cli/lib/ext/promise'); - -const readFile = Promise.denodeify(fs.readFile); - -describe('Change', () => { - let sourcePath = 'src/app/my-component'; - - beforeEach(() => { - let mockDrive = { - 'src/app/my-component': { - 'add-file.txt': 'hello', - 'remove-replace-file.txt': 'import * as foo from "./bar"', - 'replace-file.txt': 'import { FooComponent } from "./baz"' - } - }; - mockFs(mockDrive); - }); - afterEach(() => { - mockFs.restore(); - }); - - describe('InsertChange', () => { - let sourceFile = path.join(sourcePath, 'add-file.txt'); - - it('adds text to the source code', () => { - let changeInstance = new InsertChange(sourceFile, 6, ' world!'); - return changeInstance - .apply(NodeHost) - .then(() => readFile(sourceFile, 'utf8')) - .then(contents => { - expect(contents).toEqual('hello world!'); - }); - }); - it('fails for negative position', () => { - expect(() => new InsertChange(sourceFile, -6, ' world!')).toThrowError(); - }); - it('adds nothing in the source code if empty string is inserted', () => { - let changeInstance = new InsertChange(sourceFile, 6, ''); - return changeInstance - .apply(NodeHost) - .then(() => readFile(sourceFile, 'utf8')) - .then(contents => { - expect(contents).toEqual('hello'); - }); - }); - }); - - describe('RemoveChange', () => { - let sourceFile = path.join(sourcePath, 'remove-replace-file.txt'); - - it('removes given text from the source code', () => { - let changeInstance = new RemoveChange(sourceFile, 9, 'as foo'); - return changeInstance - .apply(NodeHost) - .then(() => readFile(sourceFile, 'utf8')) - .then(contents => { - expect(contents).toEqual('import * from "./bar"'); - }); - }); - it('fails for negative position', () => { - expect(() => new RemoveChange(sourceFile, -6, ' world!')).toThrow(); - }); - it('does not change the file if told to remove empty string', () => { - let changeInstance = new RemoveChange(sourceFile, 9, ''); - return changeInstance - .apply(NodeHost) - .then(() => readFile(sourceFile, 'utf8')) - .then(contents => { - expect(contents).toEqual('import * as foo from "./bar"'); - }); - }); - }); - - describe('ReplaceChange', () => { - it('replaces the given text in the source code', () => { - let sourceFile = path.join(sourcePath, 'remove-replace-file.txt'); - let changeInstance = new ReplaceChange(sourceFile, 7, '* as foo', '{ fooComponent }'); - return changeInstance - .apply(NodeHost) - .then(() => readFile(sourceFile, 'utf8')) - .then(contents => { - expect(contents).toEqual('import { fooComponent } from "./bar"'); - }); - }); - it('fails for negative position', () => { - let sourceFile = path.join(sourcePath, 'remove-replace-file.txt'); - expect(() => new ReplaceChange(sourceFile, -6, 'hello', ' world!')).toThrow(); - }); - it('fails for invalid replacement', () => { - let sourceFile = path.join(sourcePath, 'replace-file.txt'); - let changeInstance = new ReplaceChange(sourceFile, 0, 'foobar', ''); - return changeInstance - .apply(NodeHost) - .then(() => expect(false).toBe(true), err => { - // Check that the message contains the string to replace and the string from the file. - expect(err.message).toContain('foobar'); - expect(err.message).toContain('import'); - }); - }); - it('adds string to the position of an empty string', () => { - let sourceFile = path.join(sourcePath, 'replace-file.txt'); - let changeInstance = new ReplaceChange(sourceFile, 9, '', 'BarComponent, '); - return changeInstance - .apply(NodeHost) - .then(() => readFile(sourceFile, 'utf8')) - .then(contents => { - expect(contents).toEqual('import { BarComponent, FooComponent } from "./baz"'); - }); - }); - it('removes the given string only if an empty string to add is given', () => { - let sourceFile = path.join(sourcePath, 'remove-replace-file.txt'); - let changeInstance = new ReplaceChange(sourceFile, 8, ' as foo', ''); - return changeInstance - .apply(NodeHost) - .then(() => readFile(sourceFile, 'utf8')) - .then(contents => { - expect(contents).toEqual('import * from "./bar"'); - }); - }); - }); -}); diff --git a/packages/@angular-cli/ast-tools/src/change.ts b/packages/@angular-cli/ast-tools/src/change.ts deleted file mode 100644 index 14e537575b3c..000000000000 --- a/packages/@angular-cli/ast-tools/src/change.ts +++ /dev/null @@ -1,168 +0,0 @@ -import fs = require('fs'); -import denodeify = require('denodeify'); - -const readFile = (denodeify(fs.readFile) as (...args: any[]) => Promise); -const writeFile = (denodeify(fs.writeFile) as (...args: any[]) => Promise); - -export interface Host { - write(path: string, content: string): Promise; - read(path: string): Promise; -} - -export const NodeHost: Host = { - write: (path: string, content: string) => writeFile(path, content, 'utf8'), - read: (path: string) => readFile(path, 'utf8') -}; - - -export interface Change { - apply(host: Host): Promise; - - // The file this change should be applied to. Some changes might not apply to - // a file (maybe the config). - readonly path: string | null; - - // The order this change should be applied. Normally the position inside the file. - // Changes are applied from the bottom of a file to the top. - readonly order: number; - - // The description of this change. This will be outputted in a dry or verbose run. - readonly description: string; -} - - -/** - * An operation that does nothing. - */ -export class NoopChange implements Change { - description = 'No operation.'; - order = Infinity; - path: string = null; - apply() { return Promise.resolve(); } -} - -/** - * An operation that mixes two or more changes, and merge them (in order). - * Can only apply to a single file. Use a ChangeManager to apply changes to multiple - * files. - */ -export class MultiChange implements Change { - private _path: string; - private _changes: Change[]; - - constructor(...changes: (Change[] | Change)[]) { - this._changes = []; - [].concat(...changes).forEach(change => this.appendChange(change)); - } - - appendChange(change: Change) { - // Validate that the path is the same for everyone of those. - if (this._path === undefined) { - this._path = change.path; - } else if (change.path !== this._path) { - throw new Error('Cannot apply a change to a different path.'); - } - this._changes.push(change); - } - - get description() { - return `Changes:\n ${this._changes.map(x => x.description).join('\n ')}`; - } - // Always apply as early as the highest change. - get order() { return Math.max(...this._changes.map(c => c.order)); } - get path() { return this._path; } - - apply(host: Host) { - return this._changes - .sort((a: Change, b: Change) => b.order - a.order) - .reduce((promise, change) => { - return promise.then(() => change.apply(host)); - }, Promise.resolve()); - } -} - - -/** - * Will add text to the source code. - */ -export class InsertChange implements Change { - - order: number; - description: string; - - constructor(public path: string, private pos: number, private toAdd: string) { - if (pos < 0) { - throw new Error('Negative positions are invalid'); - } - this.description = `Inserted ${toAdd} into position ${pos} of ${path}`; - this.order = pos; - } - - /** - * This method does not insert spaces if there is none in the original string. - */ - apply(host: Host): Promise { - return host.read(this.path).then(content => { - let prefix = content.substring(0, this.pos); - let suffix = content.substring(this.pos); - return host.write(this.path, `${prefix}${this.toAdd}${suffix}`); - }); - } -} - -/** - * Will remove text from the source code. - */ -export class RemoveChange implements Change { - - order: number; - description: string; - - constructor(public path: string, private pos: number, private toRemove: string) { - if (pos < 0) { - throw new Error('Negative positions are invalid'); - } - this.description = `Removed ${toRemove} into position ${pos} of ${path}`; - this.order = pos; - } - - apply(host: Host): Promise { - return host.read(this.path).then(content => { - let prefix = content.substring(0, this.pos); - let suffix = content.substring(this.pos + this.toRemove.length); - // TODO: throw error if toRemove doesn't match removed string. - return host.write(this.path, `${prefix}${suffix}`); - }); - } -} - -/** - * Will replace text from the source code. - */ -export class ReplaceChange implements Change { - order: number; - description: string; - - constructor(public path: string, private pos: number, private oldText: string, - private newText: string) { - if (pos < 0) { - throw new Error('Negative positions are invalid'); - } - this.description = `Replaced ${oldText} into position ${pos} of ${path} with ${newText}`; - this.order = pos; - } - - apply(host: Host): Promise { - return host.read(this.path).then(content => { - const prefix = content.substring(0, this.pos); - const suffix = content.substring(this.pos + this.oldText.length); - const text = content.substring(this.pos, this.pos + this.oldText.length); - - if (text !== this.oldText) { - return Promise.reject(new Error(`Invalid replace: "${text}" != "${this.oldText}".`)); - } - // TODO: throw error if oldText doesn't match removed string. - return host.write(this.path, `${prefix}${this.newText}${suffix}`); - }); - } -} diff --git a/packages/@angular-cli/ast-tools/src/index.ts b/packages/@angular-cli/ast-tools/src/index.ts deleted file mode 100644 index d76151999687..000000000000 --- a/packages/@angular-cli/ast-tools/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './ast-utils'; -export * from './change'; -export * from './node'; -export * from './route-utils'; diff --git a/packages/@angular-cli/ast-tools/src/node.ts b/packages/@angular-cli/ast-tools/src/node.ts deleted file mode 100644 index e734c941b978..000000000000 --- a/packages/@angular-cli/ast-tools/src/node.ts +++ /dev/null @@ -1,47 +0,0 @@ -import ts = require('typescript'); -import {RemoveChange, Change} from './change'; - - -/** - * Find all nodes from the AST in the subtree of node of SyntaxKind kind. - * @param node - * @param kind - * @param max The maximum number of items to return. - * @return all nodes of kind, or [] if none is found - */ -export function findNodes(node: ts.Node, kind: ts.SyntaxKind, max: number = Infinity): ts.Node[] { - if (!node || max == 0) { - return []; - } - - let arr: ts.Node[] = []; - if (node.kind === kind) { - arr.push(node); - max--; - } - if (max > 0) { - for (const child of node.getChildren()) { - findNodes(child, kind, max).forEach(node => { - if (max > 0) { - arr.push(node); - } - max--; - }); - - if (max <= 0) { - break; - } - } - } - return arr; -} - - -export function removeAstNode(node: ts.Node): Change { - const source = node.getSourceFile(); - return new RemoveChange( - source.path, - node.getStart(source), - node.getFullText(source) - ); -} diff --git a/packages/@angular-cli/ast-tools/src/route-utils.spec.ts b/packages/@angular-cli/ast-tools/src/route-utils.spec.ts deleted file mode 100644 index 4405742a35f4..000000000000 --- a/packages/@angular-cli/ast-tools/src/route-utils.spec.ts +++ /dev/null @@ -1,625 +0,0 @@ -import * as mockFs from 'mock-fs'; -import * as fs from 'fs'; -import * as nru from './route-utils'; -import * as path from 'path'; -import { NodeHost, InsertChange, RemoveChange } from './change'; -import denodeify = require('denodeify'); -import * as _ from 'lodash'; -import {it} from './spec-utils'; - -const readFile = (denodeify(fs.readFile) as (...args: any[]) => Promise); - - -describe('route utils', () => { - describe('insertImport', () => { - const sourceFile = 'tmp/tmp.ts'; - beforeEach(() => { - let mockDrive = { - 'tmp': { - 'tmp.ts': '' - } - }; - mockFs(mockDrive); - }); - - afterEach(() => { - mockFs.restore(); - }); - - it('inserts as last import if not present', () => { - let content = `'use strict'\n import {foo} from 'bar'\n import * as fz from 'fizz';`; - let editedFile = new InsertChange(sourceFile, 0, content); - return editedFile.apply(NodeHost) - .then(() => nru.insertImport(sourceFile, 'Router', '@angular/router').apply(NodeHost)) - .then(() => readFile(sourceFile, 'utf8')) - .then(newContent => { - expect(newContent).toEqual(content + `\nimport { Router } from '@angular/router';`); - }); - }); - it('does not insert if present', () => { - let content = `'use strict'\n import {Router} from '@angular/router'`; - let editedFile = new InsertChange(sourceFile, 0, content); - return editedFile.apply(NodeHost) - .then(() => nru.insertImport(sourceFile, 'Router', '@angular/router')) - .then(() => readFile(sourceFile, 'utf8')) - .then(newContent => { - expect(newContent).toEqual(content); - }); - }); - it('inserts into existing import clause if import file is already cited', () => { - let content = `'use strict'\n import { foo, bar } from 'fizz'`; - let editedFile = new InsertChange(sourceFile, 0, content); - return editedFile.apply(NodeHost) - .then(() => nru.insertImport(sourceFile, 'baz', 'fizz').apply(NodeHost)) - .then(() => readFile(sourceFile, 'utf8')) - .then(newContent => { - expect(newContent).toEqual(`'use strict'\n import { foo, bar, baz } from 'fizz'`); - }); - }); - it('understands * imports', () => { - let content = `\nimport * as myTest from 'tests' \n`; - let editedFile = new InsertChange(sourceFile, 0, content); - return editedFile.apply(NodeHost) - .then(() => nru.insertImport(sourceFile, 'Test', 'tests')) - .then(() => readFile(sourceFile, 'utf8')) - .then(newContent => { - expect(newContent).toEqual(content); - }); - }); - it('inserts after use-strict', () => { - let content = `'use strict';\n hello`; - let editedFile = new InsertChange(sourceFile, 0, content); - return editedFile.apply(NodeHost) - .then(() => nru.insertImport(sourceFile, 'Router', '@angular/router').apply(NodeHost)) - .then(() => readFile(sourceFile, 'utf8')) - .then(newContent => { - expect(newContent).toEqual( - `'use strict';\nimport { Router } from '@angular/router';\n hello`); - }); - }); - it('inserts inserts at beginning of file if no imports exist', () => { - return nru.insertImport(sourceFile, 'Router', '@angular/router').apply(NodeHost) - .then(() => readFile(sourceFile, 'utf8')) - .then(newContent => { - expect(newContent).toEqual(`import { Router } from '@angular/router';\n`); - }); - }); - it('inserts subcomponent in win32 environment', () => { - let content = './level1\\level2/level2.component'; - return nru.insertImport(sourceFile, 'level2', content).apply(NodeHost) - .then(() => readFile(sourceFile, 'utf8')) - .then(newContent => { - if (process.platform.startsWith('win')) { - expect(newContent).toEqual( - `import { level2 } from './level1/level2/level2.component';\n`); - } else { - expect(newContent).toEqual( - `import { level2 } from './level1\\level2/level2.component';\n`); - } - }); - }); - }); - - describe('bootstrapItem', () => { - const mainFile = 'tmp/main.ts'; - const prefix = `import {bootstrap} from '@angular/platform-browser-dynamic'; \n` + - `import { AppComponent } from './app/';\n`; - const routes = {'provideRouter': ['@angular/router'], 'routes': ['./routes', true]}; - const toBootstrap = 'provideRouter(routes)'; - const routerImport = `import routes from './routes';\n` + - `import { provideRouter } from '@angular/router'; \n`; - beforeEach(() => { - let mockDrive = { - 'tmp': { - 'main.ts': `import {bootstrap} from '@angular/platform-browser-dynamic'; \n` + - `import { AppComponent } from './app/'; \n` + - 'bootstrap(AppComponent);' - } - }; - mockFs(mockDrive); - }); - - afterEach(() => { - mockFs.restore(); - }); - - it('adds a provideRouter import if not there already', () => { - return nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap)) - .then(() => readFile(mainFile, 'utf8')) - .then(content => { - expect(content).toEqual(prefix + routerImport + - 'bootstrap(AppComponent, [ provideRouter(routes) ]);'); - }); - }); - xit('does not add a provideRouter import if it exits already', () => { - return nru.insertImport(mainFile, 'provideRouter', '@angular/router').apply(NodeHost) - .then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap))) - .then(() => readFile(mainFile, 'utf8')) - .then(content => { - expect(content).toEqual( - `import routes from './routes'; - import { provideRouter } from '@angular/router'; - bootstrap(AppComponent, [ provideRouter(routes) ]);`); - }); - }); - xit('does not duplicate import to route.ts ', () => { - let editedFile = new InsertChange(mainFile, 100, `\nimport routes from './routes';`); - return editedFile - .apply(NodeHost) - .then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap))) - .then(() => readFile(mainFile, 'utf8')) - .then(content => { - expect(content).toEqual(prefix + routerImport + - 'bootstrap(AppComponent, [ provideRouter(routes) ]);'); - }); - }); - it('adds provideRouter to bootstrap if absent and no providers array', () => { - return nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap)) - .then(() => readFile(mainFile, 'utf8')) - .then(content => { - expect(content).toEqual(prefix + routerImport + - 'bootstrap(AppComponent, [ provideRouter(routes) ]);'); - }); - }); - it('adds provideRouter to bootstrap if absent and empty providers array', () => { - let editFile = new InsertChange(mainFile, 124, ', []'); - return editFile.apply(NodeHost) - .then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap))) - .then(() => readFile(mainFile, 'utf8')) - .then(content => { - expect(content).toEqual(prefix + routerImport + - 'bootstrap(AppComponent, [provideRouter(routes)]);'); - }); - }); - it('adds provideRouter to bootstrap if absent and non-empty providers array', () => { - let editedFile = new InsertChange(mainFile, 124, ', [ HTTP_PROVIDERS ]'); - return editedFile.apply(NodeHost) - .then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap))) - .then(() => readFile(mainFile, 'utf8')) - .then(content => { - expect(content).toEqual(prefix + routerImport + - 'bootstrap(AppComponent, [ HTTP_PROVIDERS, provideRouter(routes) ]);'); - }); - }); - it('does not add provideRouter to bootstrap if present', () => { - let editedFile = new InsertChange(mainFile, - 124, - ', [ HTTP_PROVIDERS, provideRouter(routes) ]'); - return editedFile.apply(NodeHost) - .then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap))) - .then(() => readFile(mainFile, 'utf8')) - .then(content => { - expect(content).toEqual(prefix + routerImport + - 'bootstrap(AppComponent, [ HTTP_PROVIDERS, provideRouter(routes) ]);'); - }); - }); - it('inserts into the correct array', () => { - let editedFile = new InsertChange(mainFile, 124, ', [ HTTP_PROVIDERS, {provide: [BAR]}]'); - return editedFile.apply(NodeHost) - .then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap))) - .then(() => readFile(mainFile, 'utf8')) - .then(content => { - expect(content).toEqual(prefix + routerImport + - 'bootstrap(AppComponent, [ HTTP_PROVIDERS, {provide: [BAR]}, provideRouter(routes)]);'); - }); - }); - it('throws an error if there is no or multiple bootstrap expressions', () => { - let editedFile = new InsertChange(mainFile, 126, '\n bootstrap(moreStuff);'); - return editedFile.apply(NodeHost) - .then(() => nru.bootstrapItem(mainFile, routes, toBootstrap)) - .catch(e => - expect(e.message).toEqual('Did not bootstrap provideRouter in' + - ' tmp/main.ts because of multiple or no bootstrap calls') - ); - }); - it('configures correctly if bootstrap or provide router is not at top level', () => { - let editedFile = new InsertChange(mainFile, 126, '\n if(e){bootstrap, provideRouter});'); - return editedFile.apply(NodeHost) - .then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap))) - .then(() => readFile(mainFile, 'utf8')) - .then(content => { - expect(content).toEqual(prefix + routerImport + - 'bootstrap(AppComponent, [ provideRouter(routes) ]);\n if(e){bootstrap, provideRouter});'); // tslint:disable-line - }); - }); - }); - - describe('addPathToRoutes', () => { - const routesFile = 'src/routes.ts'; - let options = {dir: 'src/app', appRoot: 'src/app', routesFile: routesFile, - component: 'NewRouteComponent', dasherizedName: 'new-route'}; - const nestedRoutes = `\n { path: 'home', component: HomeComponent, - children: [ - { path: 'about', component: AboutComponent, - children: [ - { path: 'more', component: MoreComponent } - ] - } - ] - }\n`; - beforeEach(() => { - let mockDrive = { - 'src': { - 'routes.ts' : 'export default [];' - } - }; - mockFs(mockDrive); - }); - - afterEach(() => { - mockFs.restore(); - }); - - it('adds import to new route component if absent', () => { - return nru.applyChanges(nru.addPathToRoutes(routesFile, - _.merge({route: 'new-route'}, options))) - .then(() => readFile(routesFile, 'utf8')) - .then(content => { - expect(content).toEqual( - `import { NewRouteComponent } from './app/new-route/new-route.component'; -export default [\n { path: 'new-route', component: NewRouteComponent }\n];`); - }); - }); - it('throws error if multiple export defaults exist', () => { - let editedFile = new InsertChange(routesFile, 20, 'export default {}'); - return editedFile.apply(NodeHost).then(() => { - return nru.addPathToRoutes(routesFile, _.merge({route: 'new-route'}, options)); - }).catch(e => { - expect(e.message).toEqual('Did not insert path in routes.ts because ' - + `there were multiple or no 'export default' statements`); - }); - }); - it('throws error if no export defaults exists', () => { - let editedFile = new RemoveChange(routesFile, 0, 'export default []'); - return editedFile.apply(NodeHost).then(() => { - return nru.addPathToRoutes(routesFile, _.merge({route: 'new-route'}, options)); - }).catch(e => { - expect(e.message).toEqual('Did not insert path in routes.ts because ' - + `there were multiple or no 'export default' statements`); - }); - }); - it('treats positional params correctly', () => { - let editedFile = new InsertChange(routesFile, 16, - `\n { path: 'home', component: HomeComponent }\n`); - return editedFile.apply(NodeHost).then(() => { - options.dasherizedName = 'about'; - options.component = 'AboutComponent'; - return nru.applyChanges( - nru.addPathToRoutes(routesFile, _.merge({route: 'home/about/:id'}, options))); }) - .then(() => readFile(routesFile, 'utf8')) - .then(content => { - expect(content).toEqual( - `import { AboutComponent } from './app/home/about/about.component';` + - `\nexport default [\n` + - ` { path: 'home', component: HomeComponent,\n` + - ` children: [\n` + - ` { path: 'about/:id', component: AboutComponent }` + - `\n ]\n }\n];`); - }); - }); - it('inserts under parent, mid', () => { - let editedFile = new InsertChange(routesFile, 16, nestedRoutes); - return editedFile.apply(NodeHost).then(() => { - options.dasherizedName = 'details'; - options.component = 'DetailsComponent'; - return nru.applyChanges( - nru.addPathToRoutes(routesFile, _.merge({route: 'home/about/details'}, options))); }) - .then(() => readFile(routesFile, 'utf8')) - .then(content => { - // tslint:disable-next-line - let expected = `import { DetailsComponent } from './app/home/about/details/details.component'; -export default [ - { path: 'home', component: HomeComponent, - children: [ - { path: 'about', component: AboutComponent, - children: [ - { path: 'details', component: DetailsComponent }, - { path: 'more', component: MoreComponent } - ] - } - ] - }\n];`; - expect(content).toEqual(expected); - }); - }); - it('inserts under parent, deep', () => { - let editedFile = new InsertChange(routesFile, 16, nestedRoutes); - return editedFile.apply(NodeHost).then(() => { - options.dasherizedName = 'sections'; - options.component = 'SectionsComponent'; - return nru.applyChanges( - nru.addPathToRoutes(routesFile, - _.merge({route: 'home/about/more/sections'}, options))); }) - .then(() => readFile(routesFile, 'utf8')) - .then(content => { - // tslint:disable-next-line - let expected = `import { SectionsComponent } from './app/home/about/more/sections/sections.component'; -export default [ - { path: 'home', component: HomeComponent, - children: [ - { path: 'about', component: AboutComponent, - children: [ - { path: 'more', component: MoreComponent, - children: [ - { path: 'sections', component: SectionsComponent } - ] - } - ] - } - ] - } -];`; - expect(content).toEqual(expected); - }); - }); - it('works well with multiple routes in a level', () => { - let paths = `\n { path: 'main', component: MainComponent } - { path: 'home', component: HomeComponent, - children: [ - { path: 'about', component: AboutComponent } - ] - }\n`; - let editedFile = new InsertChange(routesFile, 16, paths); - return editedFile.apply(NodeHost).then(() => { - options.dasherizedName = 'about'; - options.component = 'AboutComponent_1'; - return nru.applyChanges( - nru.addPathToRoutes(routesFile, _.merge({route: 'home/about/:id'}, options))); }) - .then(() => readFile(routesFile, 'utf8')) - .then(content => { - // tslint:disable-next-line - expect(content).toEqual(`import { AboutComponent_1 } from './app/home/about/about.component'; -export default [ - { path: 'main', component: MainComponent } - { path: 'home', component: HomeComponent, - children: [ - { path: 'about/:id', component: AboutComponent_1 }, - { path: 'about', component: AboutComponent } - ] - } -];` - ); - }); - }); - it('throws error if repeating child, shallow', () => { - let editedFile = new InsertChange(routesFile, 16, nestedRoutes); - return editedFile.apply(NodeHost).then(() => { - options.dasherizedName = 'home'; - options.component = 'HomeComponent'; - return nru.addPathToRoutes(routesFile, _.merge({route: '/home'}, options)); - }).catch(e => { - expect(e.message).toEqual('Route was not added since it is a duplicate'); - }); - }); - it('throws error if repeating child, mid', () => { - let editedFile = new InsertChange(routesFile, 16, nestedRoutes); - return editedFile.apply(NodeHost).then(() => { - options.dasherizedName = 'about'; - options.component = 'AboutComponent'; - return nru.addPathToRoutes(routesFile, _.merge({route: 'home/about/'}, options)); - }).catch(e => { - expect(e.message).toEqual('Route was not added since it is a duplicate'); - }); - }); - it('throws error if repeating child, deep', () => { - let editedFile = new InsertChange(routesFile, 16, nestedRoutes); - return editedFile.apply(NodeHost).then(() => { - options.dasherizedName = 'more'; - options.component = 'MoreComponent'; - return nru.addPathToRoutes(routesFile, _.merge({route: 'home/about/more'}, options)); - }).catch(e => { - expect(e.message).toEqual('Route was not added since it is a duplicate'); - }); - }); - it('does not report false repeat', () => { - let editedFile = new InsertChange(routesFile, 16, nestedRoutes); - return editedFile.apply(NodeHost).then(() => { - options.dasherizedName = 'more'; - options.component = 'MoreComponent'; - return nru.applyChanges(nru.addPathToRoutes(routesFile, _.merge({route: 'more'}, options))); - }) - .then(() => readFile(routesFile, 'utf8')) - .then(content => { - let expected = `import { MoreComponent } from './app/more/more.component'; -export default [ - { path: 'more', component: MoreComponent }, - { path: 'home', component: HomeComponent, - children: [ - { path: 'about', component: AboutComponent, - children: [ - { path: 'more', component: MoreComponent } - ] - } - ] - }\n];`; - expect(content).toEqual(expected); - }); - }); - it('does not report false repeat: multiple paths on a level', () => { - - let routes = `\n { path: 'home', component: HomeComponent, - children: [ - { path: 'about', component: AboutComponent, - children: [ - { path: 'more', component: MoreComponent } - ] - } - ] - },\n { path: 'trap-queen', component: TrapQueenComponent}\n`; - - let editedFile = new InsertChange(routesFile, 16, routes); - return editedFile.apply(NodeHost).then(() => { - options.dasherizedName = 'trap-queen'; - options.component = 'TrapQueenComponent'; - return nru.applyChanges( - nru.addPathToRoutes(routesFile, _.merge({route: 'home/trap-queen'}, options))); - }) - .then(() => readFile(routesFile, 'utf8')) - .then(content => { - // tslint:disable-next-line - let expected = `import { TrapQueenComponent } from './app/home/trap-queen/trap-queen.component'; -export default [ - { path: 'home', component: HomeComponent, - children: [ - { path: 'trap-queen', component: TrapQueenComponent }, - { path: 'about', component: AboutComponent, - children: [ - { path: 'more', component: MoreComponent } - ] - } - ] - },\n { path: 'trap-queen', component: TrapQueenComponent}\n];`; - expect(content).toEqual(expected); - }); - }); - it('resolves imports correctly', () => { - let editedFile = new InsertChange(routesFile, 16, - `\n { path: 'home', component: HomeComponent }\n`); - return editedFile.apply(NodeHost).then(() => { - let editedFile = new InsertChange(routesFile, 0, - `import { HomeComponent } from './app/home/home.component';\n`); - return editedFile.apply(NodeHost); - }) - .then(() => { - options.dasherizedName = 'home'; - options.component = 'HomeComponent'; - return nru.applyChanges( - nru.addPathToRoutes(routesFile, _.merge({route: 'home/home'}, options))); }) - .then(() => readFile(routesFile, 'utf8')) - .then(content => { - let expected = `import { HomeComponent } from './app/home/home.component'; -import { HomeComponent as HomeComponent_1 } from './app/home/home/home.component'; -export default [ - { path: 'home', component: HomeComponent, - children: [ - { path: 'home', component: HomeComponent_1 } - ] - } -];`; - expect(content).toEqual(expected); - }); - }); - it('throws error if components collide and there is repitition', () => { - let editedFile = new InsertChange(routesFile, 16, - `\n { path: 'about', component: AboutComponent, - children: [ - { path: 'details/:id', component: DetailsComponent_1 }, - { path: 'details', component: DetailsComponent } - ] - }`); - return editedFile.apply(NodeHost).then(() => { - let editedFile = new InsertChange(routesFile, 0, - `import { AboutComponent } from './app/about/about.component'; -import { DetailsComponent } from './app/about/details/details.component'; -import { DetailsComponent as DetailsComponent_1 } from './app/about/description/details.component;\n`); // tslint:disable-line - return editedFile.apply(NodeHost); - }).then(() => { - options.dasherizedName = 'details'; - options.component = 'DetailsComponent'; - expect(() => nru.addPathToRoutes(routesFile, _.merge({route: 'about/details'}, options))) - .toThrowError(); - }); - }); - - it('adds guard to parent route: addItemsToRouteProperties', () => { - let path = `\n { path: 'home', component: HomeComponent }\n`; - let editedFile = new InsertChange(routesFile, 16, path); - return editedFile.apply(NodeHost).then(() => { - let toInsert = {'home': ['canActivate', '[ MyGuard ]'] }; - return nru.applyChanges(nru.addItemsToRouteProperties(routesFile, toInsert)); - }) - .then(() => readFile(routesFile, 'utf8')) - .then(content => { - expect(content).toEqual(`export default [ - { path: 'home', component: HomeComponent, canActivate: [ MyGuard ] } -];` - ); - }); - }); - it('adds guard to child route: addItemsToRouteProperties', () => { - let path = `\n { path: 'home', component: HomeComponent }\n`; - let editedFile = new InsertChange(routesFile, 16, path); - return editedFile.apply(NodeHost).then(() => { - options.dasherizedName = 'more'; - options.component = 'MoreComponent'; - return nru.applyChanges( - nru.addPathToRoutes(routesFile, _.merge({route: 'home/more'}, options))); }) - .then(() => { - return nru.applyChanges(nru.addItemsToRouteProperties(routesFile, - { 'home/more': ['canDeactivate', '[ MyGuard ]'] })); }) - .then(() => { - return nru.applyChanges(nru.addItemsToRouteProperties( - routesFile, { 'home/more': ['useAsDefault', 'true'] })); }) - .then(() => readFile(routesFile, 'utf8')) - .then(content => { - expect(content).toEqual( - `import { MoreComponent } from './app/home/more/more.component'; -export default [ - { path: 'home', component: HomeComponent, - children: [ - { path: 'more', component: MoreComponent, canDeactivate: [ MyGuard ], useAsDefault: true } - ] - } -];` - ); - }); - }); - }); - - describe('validators', () => { - const projectRoot = process.cwd(); - const componentFile = path.join(projectRoot, 'src/app/about/about.component.ts'); - beforeEach(() => { - let mockDrive = { - 'src': { - 'app': { - 'about': { - 'about.component.ts' : 'export class AboutComponent { }' - } - } - } - }; - mockFs(mockDrive); - }); - - afterEach(() => { - mockFs.restore(); - }); - - it('accepts component name without \'component\' suffix: resolveComponentPath', () => { - let fileName = nru.resolveComponentPath(projectRoot, 'src/app', 'about'); - expect(fileName).toEqual(componentFile); - }); - it('accepts component name with \'component\' suffix: resolveComponentPath', () => { - let fileName = nru.resolveComponentPath(projectRoot, 'src/app', 'about.component'); - expect(fileName).toEqual(componentFile); - }); - it('accepts path absolute from project root: resolveComponentPath', () => { - let fileName = nru.resolveComponentPath(projectRoot, '', `${path.sep}about`); - expect(fileName).toEqual(componentFile); - }); - it('accept component with directory name: resolveComponentPath', () => { - let fileName = nru.resolveComponentPath(projectRoot, 'src/app', 'about/about.component'); - expect(fileName).toEqual(componentFile); - }); - - it('finds component name: confirmComponentExport', () => { - let exportExists = nru.confirmComponentExport(componentFile, 'AboutComponent'); - expect(exportExists).toBeTruthy(); - }); - it('finds component in the presence of decorators: confirmComponentExport', () => { - let editedFile = new InsertChange(componentFile, 0, '@Component{}\n'); - return editedFile.apply(NodeHost).then(() => { - let exportExists = nru.confirmComponentExport(componentFile, 'AboutComponent'); - expect(exportExists).toBeTruthy(); - }); - }); - it('report absence of component name: confirmComponentExport', () => { - let editedFile = new RemoveChange(componentFile, 21, 'onent'); - return editedFile.apply(NodeHost).then(() => { - let exportExists = nru.confirmComponentExport(componentFile, 'AboutComponent'); - expect(exportExists).not.toBeTruthy(); - }); - }); - }); -}); diff --git a/packages/@angular-cli/ast-tools/src/route-utils.ts b/packages/@angular-cli/ast-tools/src/route-utils.ts deleted file mode 100644 index 4455bafdc814..000000000000 --- a/packages/@angular-cli/ast-tools/src/route-utils.ts +++ /dev/null @@ -1,557 +0,0 @@ -import * as ts from 'typescript'; -import * as fs from 'fs'; -import * as path from 'path'; -import {Change, InsertChange, NoopChange} from './change'; -import {findNodes} from './node'; -import {insertAfterLastOccurrence} from './ast-utils'; -import {NodeHost, Host} from './change'; - -/** - * Adds imports to mainFile and adds toBootstrap to the array of providers - * in bootstrap, if not present - * @param mainFile main.ts - * @param imports Object { importedClass: ['path/to/import/from', defaultStyleImport?] } - * @param toBootstrap - */ -export function bootstrapItem( - mainFile: string, - imports: {[key: string]: (string | boolean)[]}, - toBootstrap: string -) { - let changes = Object.keys(imports).map(importedClass => { - let defaultStyleImport = imports[importedClass].length === 2 && !!imports[importedClass][1]; - return insertImport( - mainFile, - importedClass, - imports[importedClass][0].toString(), - defaultStyleImport - ); - }); - let rootNode = getRootNode(mainFile); - // get ExpressionStatements from the top level syntaxList of the sourceFile - let bootstrapNodes = rootNode.getChildAt(0).getChildren().filter(node => { - // get bootstrap expressions - return node.kind === ts.SyntaxKind.ExpressionStatement && - (node.getChildAt(0).getChildAt(0) as ts.Identifier).text.toLowerCase() === 'bootstrap'; - }); - if (bootstrapNodes.length !== 1) { - throw new Error(`Did not bootstrap provideRouter in ${mainFile}` + - ' because of multiple or no bootstrap calls'); - } - let bootstrapNode = bootstrapNodes[0].getChildAt(0); - let isBootstraped = findNodes(bootstrapNode, ts.SyntaxKind.SyntaxList) // get bootstrapped items - .reduce((a, b) => a.concat(b.getChildren().map(n => n.getText())), []) - .filter(n => n !== ',') - .indexOf(toBootstrap) !== -1; - if (isBootstraped) { - return changes; - } - // if bracket exitst already, add configuration template, - // otherwise, insert into bootstrap parens - let fallBackPos: number, configurePathsTemplate: string, separator: string; - let syntaxListNodes: any; - let bootstrapProviders = bootstrapNode.getChildAt(2).getChildAt(2); // array of providers - - if ( bootstrapProviders ) { - syntaxListNodes = bootstrapProviders.getChildAt(1).getChildren(); - fallBackPos = bootstrapProviders.getChildAt(2).pos; // closeBracketLiteral - separator = syntaxListNodes.length === 0 ? '' : ', '; - configurePathsTemplate = `${separator}${toBootstrap}`; - } else { - fallBackPos = bootstrapNode.getChildAt(3).pos; // closeParenLiteral - syntaxListNodes = bootstrapNode.getChildAt(2).getChildren(); - configurePathsTemplate = `, [ ${toBootstrap} ]`; - } - - changes.push(insertAfterLastOccurrence(syntaxListNodes, configurePathsTemplate, - mainFile, fallBackPos)); - return changes; -} - -/** -* Add Import `import { symbolName } from fileName` if the import doesn't exit -* already. Assumes fileToEdit can be resolved and accessed. -* @param fileToEdit (file we want to add import to) -* @param symbolName (item to import) -* @param fileName (path to the file) -* @param isDefault (if true, import follows style for importing default exports) -* @return Change -*/ - -export function insertImport(fileToEdit: string, symbolName: string, - fileName: string, isDefault = false): Change { - if (process.platform.startsWith('win')) { - fileName = fileName.replace(/\\/g, '/'); // correction in windows - } - let rootNode = getRootNode(fileToEdit); - let allImports = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration); - - // get nodes that map to import statements from the file fileName - let relevantImports = allImports.filter(node => { - // StringLiteral of the ImportDeclaration is the import file (fileName in this case). - let importFiles = node.getChildren().filter(child => child.kind === ts.SyntaxKind.StringLiteral) - .map(n => (n).text); - return importFiles.filter(file => file === fileName).length === 1; - }); - - if (relevantImports.length > 0) { - - let importsAsterisk = false; - // imports from import file - let imports: ts.Node[] = []; - relevantImports.forEach(n => { - Array.prototype.push.apply(imports, findNodes(n, ts.SyntaxKind.Identifier)); - if (findNodes(n, ts.SyntaxKind.AsteriskToken).length > 0) { - importsAsterisk = true; - } - }); - - // if imports * from fileName, don't add symbolName - if (importsAsterisk) { - return; - } - - let importTextNodes = imports.filter(n => (n).text === symbolName); - - // insert import if it's not there - if (importTextNodes.length === 0) { - let fallbackPos = findNodes(relevantImports[0], ts.SyntaxKind.CloseBraceToken)[0].pos || - findNodes(relevantImports[0], ts.SyntaxKind.FromKeyword)[0].pos; - return insertAfterLastOccurrence(imports, `, ${symbolName}`, fileToEdit, fallbackPos); - } - return new NoopChange(); - } - - // no such import declaration exists - let useStrict = findNodes(rootNode, ts.SyntaxKind.StringLiteral) - .filter((n: ts.StringLiteral) => n.text === 'use strict'); - let fallbackPos = 0; - if (useStrict.length > 0) { - fallbackPos = useStrict[0].end; - } - let open = isDefault ? '' : '{ '; - let close = isDefault ? '' : ' }'; - // if there are no imports or 'use strict' statement, insert import at beginning of file - let insertAtBeginning = allImports.length === 0 && useStrict.length === 0; - let separator = insertAtBeginning ? '' : ';\n'; - let toInsert = `${separator}import ${open}${symbolName}${close}` + - ` from '${fileName}'${insertAtBeginning ? ';\n' : ''}`; - return insertAfterLastOccurrence( - allImports, - toInsert, - fileToEdit, - fallbackPos, - ts.SyntaxKind.StringLiteral - ); -}; - -/** - * Inserts a path to the new route into src/routes.ts if it doesn't exist - * @param routesFile - * @param pathOptions - * @return Change[] - * @throws Error if routesFile has multiple export default or none. - */ -export function addPathToRoutes(routesFile: string, pathOptions: any): Change[] { - let route = pathOptions.route.split('/') - .filter((n: string) => n !== '').join('/'); // change say `/about/:id/` to `about/:id` - let isDefault = pathOptions.isDefault ? ', useAsDefault: true' : ''; - let outlet = pathOptions.outlet ? `, outlet: '${pathOptions.outlet}'` : ''; - - // create route path and resolve component import - let positionalRoutes = /\/:[^/]*/g; - let routePath = route.replace(positionalRoutes, ''); - routePath = `./app/${routePath}/${pathOptions.dasherizedName}.component`; - let originalComponent = pathOptions.component; - pathOptions.component = resolveImportName( - pathOptions.component, - routePath, - pathOptions.routesFile - ); - - let content = `{ path: '${route}', component: ${pathOptions.component}${isDefault}${outlet} }`; - let rootNode = getRootNode(routesFile); - let routesNode = rootNode.getChildAt(0).getChildren().filter(n => { - // get export statement - return n.kind === ts.SyntaxKind.ExportAssignment && - n.getFullText().indexOf('export default') !== -1; - }); - if (routesNode.length !== 1) { - throw new Error('Did not insert path in routes.ts because ' + - `there were multiple or no 'export default' statements`); - } - let pos = routesNode[0].getChildAt(2).getChildAt(0).end; // openBracketLiteral - // all routes in export route array - let routesArray = routesNode[0].getChildAt(2).getChildAt(1) - .getChildren() - .filter(n => n.kind === ts.SyntaxKind.ObjectLiteralExpression); - - if (pathExists(routesArray, route, pathOptions.component)) { - // don't duplicate routes - throw new Error('Route was not added since it is a duplicate'); - } - let isChild = false; - // get parent to insert under - let parent: ts.Node; - if (pathOptions.parent) { - // append '_' to route to find the actual parent (not parent of the parent) - parent = getParent(routesArray, `${pathOptions.parent}/_`); - if (!parent) { - throw new Error( - `You specified parent '${pathOptions.parent}'' which was not found in routes.ts` - ); - } - if (route.indexOf(pathOptions.parent) === 0) { - route = route.substring(pathOptions.parent.length); - } - } else { - parent = getParent(routesArray, route); - } - - if (parent) { - let childrenInfo = addChildPath(parent, pathOptions, route); - if (!childrenInfo) { - // path exists already - throw new Error('Route was not added since it is a duplicate'); - } - content = childrenInfo.newContent; - pos = childrenInfo.pos; - isChild = true; - } - - let isFirstElement = routesArray.length === 0; - if (!isChild) { - let separator = isFirstElement ? '\n' : ','; - content = `\n ${content}${separator}`; - } - let changes: Change[] = [new InsertChange(routesFile, pos, content)]; - let component = originalComponent === pathOptions.component ? originalComponent : - `${originalComponent} as ${pathOptions.component}`; - routePath = routePath.replace(/\\/, '/'); // correction in windows - changes.push(insertImport(routesFile, component, routePath)); - return changes; -} - - -/** - * Add more properties to the route object in routes.ts - * @param routesFile routes.ts - * @param routes Object {route: [key, value]} - */ -export function addItemsToRouteProperties(routesFile: string, routes: {[key: string]: string[]}) { - let rootNode = getRootNode(routesFile); - let routesNode = rootNode.getChildAt(0).getChildren().filter(n => { - // get export statement - return n.kind === ts.SyntaxKind.ExportAssignment && - n.getFullText().indexOf('export default') !== -1; - }); - if (routesNode.length !== 1) { - throw new Error('Did not insert path in routes.ts because ' + - `there were multiple or no 'export default' statements`); - } - let routesArray = routesNode[0].getChildAt(2).getChildAt(1) - .getChildren() - .filter(n => n.kind === ts.SyntaxKind.ObjectLiteralExpression); - let changes: Change[] = Object.keys(routes).reduce((result, route) => { - // let route = routes[guardName][0]; - let itemKey = routes[route][0]; - let itemValue = routes[route][1]; - let currRouteNode = getParent(routesArray, `${route}/_`); - if (!currRouteNode) { - throw new Error(`Could not find '${route}' in routes.ts`); - } - let fallBackPos = findNodes(currRouteNode, ts.SyntaxKind.CloseBraceToken).pop().pos; - let pathPropertiesNodes = currRouteNode.getChildAt(1).getChildren() - .filter(n => n.kind === ts.SyntaxKind.PropertyAssignment); - return result.concat([insertAfterLastOccurrence(pathPropertiesNodes, - `, ${itemKey}: ${itemValue}`, routesFile, fallBackPos)]); - }, []); - return changes; -} - -/** - * Verifies that a component file exports a class of the component - * @param file - * @param componentName - * @return whether file exports componentName - */ -export function confirmComponentExport (file: string, componentName: string): boolean { - const rootNode = getRootNode(file); - let exportNodes = rootNode.getChildAt(0).getChildren().filter(n => { - return n.kind === ts.SyntaxKind.ClassDeclaration && - (n.getChildren().filter((p: ts.Identifier) => p.text === componentName).length !== 0); - }); - return exportNodes.length > 0; -} - -/** - * Ensures there is no collision between import names. If a collision occurs, resolve by adding - * underscore number to the name - * @param importName - * @param importPath path to import component from - * @param fileName (file to add import to) - * @return resolved importName - */ -function resolveImportName (importName: string, importPath: string, fileName: string): string { - const rootNode = getRootNode(fileName); - // get all the import names - let importNodes = rootNode.getChildAt(0).getChildren() - .filter(n => n.kind === ts.SyntaxKind.ImportDeclaration); - // check if imported file is same as current one before updating component name - let importNames = importNodes - .reduce((a, b) => { - let importFrom = findNodes(b, ts.SyntaxKind.StringLiteral); // there's only one - if ((importFrom.pop() as ts.StringLiteral).text !== importPath) { - // importing from different file, add to imported components to inspect - // if only one identifier { FooComponent }, if two { FooComponent as FooComponent_1 } - // choose last element of identifier array in both cases - return a.concat([findNodes(b, ts.SyntaxKind.Identifier).pop()]); - } - return a; - }, []) - .map(n => n.text); - - const index = importNames.indexOf(importName); - if (index === -1) { - return importName; - } - const baseName = importNames[index].split('_')[0]; - let newName = baseName; - let resolutionNumber = 1; - while (importNames.indexOf(newName) !== -1) { - newName = `${baseName}_${resolutionNumber}`; - resolutionNumber++; - } - return newName; -} - -/** - * Resolve a path to a component file. If the path begins with path.sep, it is treated to be - * absolute from the app/ directory. Otherwise, it is relative to currDir - * @param projectRoot - * @param currentDir - * @param filePath componentName or path to componentName - * @return component file name - * @throw Error if component file referenced by path is not found - */ -export function resolveComponentPath(projectRoot: string, currentDir: string, filePath: string) { - - let parsedPath = path.parse(filePath); - let componentName = parsedPath.base.split('.')[0]; - let componentDir = path.parse(parsedPath.dir).base; - - // correction for a case where path is /**/componentName/componentName(.component.ts) - if ( componentName === componentDir) { - filePath = parsedPath.dir; - } - if (parsedPath.dir === '') { - // only component file name is given - filePath = componentName; - } - let directory = filePath[0] === path.sep ? - path.resolve(path.join(projectRoot, 'src', 'app', filePath)) : - path.resolve(currentDir, filePath); - - if (!fs.existsSync(directory)) { - throw new Error(`path '${filePath}' must be relative to current directory` + - ` or absolute from project root`); - } - if (directory.indexOf('src' + path.sep + 'app') === -1) { - throw new Error('Route must be within app'); - } - let componentFile = path.join(directory, `${componentName}.component.ts`); - if (!fs.existsSync(componentFile)) { - throw new Error(`could not find component file referenced by ${filePath}`); - } - return componentFile; -} - -/** - * Sort changes in decreasing order and apply them. - * @param changes - * @param host - * @return Promise - */ -export function applyChanges(changes: Change[], host: Host = NodeHost): Promise { - return changes - .filter(change => !!change) - .sort((curr, next) => next.order - curr.order) - .reduce((newChange, change) => newChange.then(() => change.apply(host)), Promise.resolve()); -} -/** - * Helper for addPathToRoutes. Adds child array to the appropriate position in the routes.ts file - * @return Object (pos, newContent) - */ -function addChildPath (parentObject: ts.Node, pathOptions: any, route: string) { - if (!parentObject) { - return; - } - let pos: number; - let newContent: string; - - // get object with 'children' property - let childrenNode = parentObject.getChildAt(1).getChildren() - .filter(n => - n.kind === ts.SyntaxKind.PropertyAssignment - && ((n as ts.PropertyAssignment).name as ts.Identifier).text === 'children'); - // find number of spaces to pad nested paths - let nestingLevel = 1; // for indenting route object in the `children` array - let n = parentObject; - while (n.parent) { - if (n.kind === ts.SyntaxKind.ObjectLiteralExpression - || n.kind === ts.SyntaxKind.ArrayLiteralExpression) { - nestingLevel ++; - } - n = n.parent; - } - - // strip parent route - let parentRoute = (parentObject.getChildAt(1).getChildAt(0).getChildAt(2) as ts.Identifier).text; - let childRoute = route.substring(route.indexOf(parentRoute) + parentRoute.length + 1); - - let isDefault = pathOptions.isDefault ? ', useAsDefault: true' : ''; - let outlet = pathOptions.outlet ? `, outlet: '${pathOptions.outlet}'` : ''; - let content = `{ path: '${childRoute}', component: ${pathOptions.component}` + - `${isDefault}${outlet} }`; - let spaces = Array(2 * nestingLevel + 1).join(' '); - - if (childrenNode.length !== 0) { - // add to beginning of children array - pos = childrenNode[0].getChildAt(2).getChildAt(1).pos; // open bracket - newContent = `\n${spaces}${content},`; - } else { - // no children array, add one - pos = parentObject.getChildAt(2).pos; // close brace - newContent = `,\n${spaces.substring(2)}children: [\n${spaces}${content}` + - `\n${spaces.substring(2)}]\n${spaces.substring(5)}`; - } - return {newContent: newContent, pos: pos}; -} - -/** - * Helper for addPathToRoutes. - * @return parentNode which contains the children array to add a new path to or - * undefined if none or the entire route was matched. - */ -function getParent(routesArray: ts.Node[], route: string, parent?: ts.Node): ts.Node { - if (routesArray.length === 0 && !parent) { - return; // no children array and no parent found - } - if (route.length === 0) { - return; // route has been completely matched - } - let splitRoute = route.split('/'); - // don't treat positional parameters separately - if (splitRoute.length > 1 && splitRoute[1].indexOf(':') !== -1) { - let actualRoute = splitRoute.shift(); - splitRoute[0] = `${actualRoute}/${splitRoute[0]}`; - } - let potentialParents: ts.Node[] = routesArray // route nodes with same path as current route - .filter(n => getValueForKey(n, 'path') === splitRoute[0]); - if (potentialParents.length !== 0) { - splitRoute.shift(); // matched current parent, move on - route = splitRoute.join('/'); - } - // get all children paths - let newRouteArray = getChildrenArray(routesArray); - if (route && parent && potentialParents.length === 0) { - return parent; // final route is not matched. assign parent from here - } - parent = potentialParents.sort((a, b) => a.pos - b.pos).shift(); - return getParent(newRouteArray, route, parent); -} - -/** - * Helper for addPathToRoutes. - * @return whether path with same route and component exists - */ -function pathExists( - routesArray: ts.Node[], - route: string, - component: string, - fullRoute?: string -): boolean { - if (routesArray.length === 0) { - return false; - } - fullRoute = fullRoute ? fullRoute : route; - let sameRoute = false; - let splitRoute = route.split('/'); - // don't treat positional parameters separately - if (splitRoute.length > 1 && splitRoute[1].indexOf(':') !== -1) { - let actualRoute = splitRoute.shift(); - splitRoute[0] = `${actualRoute}/${splitRoute[0]}`; - } - let repeatedRoutes: ts.Node[] = routesArray.filter(n => { - let currentRoute = getValueForKey(n, 'path'); - let sameComponent = getValueForKey(n, 'component') === component; - - sameRoute = currentRoute === splitRoute[0]; - // Confirm that it's parents are the same - if (sameRoute && sameComponent) { - let path = currentRoute; - let objExp = n.parent; - while (objExp) { - if (objExp.kind === ts.SyntaxKind.ObjectLiteralExpression) { - let currentParentPath = getValueForKey(objExp, 'path'); - path = currentParentPath ? `${currentParentPath}/${path}` : path; - } - objExp = objExp.parent; - } - return path === fullRoute; - } - return false; - }); - - if (sameRoute) { - splitRoute.shift(); // matched current parent, move on - route = splitRoute.join('/'); - } - if (repeatedRoutes.length !== 0) { - return true; // new path will be repeating if inserted. report that path already exists - } - - // all children paths - let newRouteArray = getChildrenArray(routesArray); - return pathExists(newRouteArray, route, component, fullRoute); -} - -/** - * Helper for getParent and pathExists - * @return array with all nodes holding children array under routes - * in routesArray - */ -function getChildrenArray(routesArray: ts.Node[]): ts.Node[] { - return routesArray.reduce((allRoutes, currRoute) => allRoutes.concat( - currRoute.getChildAt(1).getChildren() - .filter(n => n.kind === ts.SyntaxKind.PropertyAssignment - && ((n as ts.PropertyAssignment).name as ts.Identifier).text === 'children') - .map(n => n.getChildAt(2).getChildAt(1)) // syntaxList containing chilren paths - .reduce((childrenArray, currChild) => childrenArray.concat(currChild.getChildren() - .filter(p => p.kind === ts.SyntaxKind.ObjectLiteralExpression) - ), []) - ), []); -} - -/** - * Helper method to get the path text or component - * @param objectLiteralNode - * @param key 'path' or 'component' - */ -function getValueForKey(objectLiteralNode: ts.Node, key: string) { - let currentNode = key === 'component' ? objectLiteralNode.getChildAt(1).getChildAt(2) : - objectLiteralNode.getChildAt(1).getChildAt(0); - return currentNode - && currentNode.getChildAt(0) - && (currentNode.getChildAt(0) as ts.Identifier).text === key - && currentNode.getChildAt(2) - && (currentNode.getChildAt(2) as ts.Identifier).text; -} - -/** - * Helper method to get AST from file - * @param file - */ -function getRootNode(file: string) { - return ts.createSourceFile(file, fs.readFileSync(file).toString(), ts.ScriptTarget.ES6, true); -} diff --git a/packages/@angular-cli/ast-tools/src/spec-utils.ts b/packages/@angular-cli/ast-tools/src/spec-utils.ts deleted file mode 100644 index 4db51445a032..000000000000 --- a/packages/@angular-cli/ast-tools/src/spec-utils.ts +++ /dev/null @@ -1,26 +0,0 @@ -// This file exports a version of the Jasmine `it` that understands promises. -// To use this, simply `import {it} from './spec-utils`. -// TODO(hansl): move this to its own Jasmine-TypeScript package. - -function async(fn: () => PromiseLike | void) { - return (done: DoneFn) => { - let result: PromiseLike | void = null; - - try { - result = fn(); - - if (result && 'then' in result) { - (result as Promise).then(done, done.fail); - } else { - done(); - } - } catch (err) { - done.fail(err); - } - }; -} - - -export function it(description: string, fn: () => PromiseLike | void) { - return (global as any)['it'](description, async(fn)); -} diff --git a/packages/@angular-cli/ast-tools/tsconfig.json b/packages/@angular-cli/ast-tools/tsconfig.json deleted file mode 100644 index 584a9436cc7b..000000000000 --- a/packages/@angular-cli/ast-tools/tsconfig.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "compilerOptions": { - "declaration": true, - "experimentalDecorators": true, - "mapRoot": "", - "module": "commonjs", - "moduleResolution": "node", - "noEmitOnError": true, - "noImplicitAny": true, - "outDir": "../../../dist/@angular-cli/ast-tools", - "rootDir": ".", - "sourceMap": true, - "sourceRoot": "/", - "target": "es5", - "lib": ["es6"], - "typeRoots": [ - "../../node_modules/@types" - ], - "types": [ - "jasmine", - "node" - ] - }, - "exclude": [ - "**/*.spec.ts" - ] -} diff --git a/packages/@angular-cli/base-href-webpack/package.json b/packages/@angular-cli/base-href-webpack/package.json deleted file mode 100644 index 6a69d21a485f..000000000000 --- a/packages/@angular-cli/base-href-webpack/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "@angular-cli/base-href-webpack", - "version": "1.0.10", - "description": "Base HREF Webpack plugin", - "main": "./src/index.js", - "keywords": [ - "angular", - "cli", - "webpack", - "plugin", - "tool" - ], - "repository": { - "type": "git", - "url": "https://github.com/angular/angular-cli.git" - }, - "author": "angular", - "license": "MIT", - "bugs": { - "url": "https://github.com/angular/angular-cli/issues" - }, - "homepage": "https://github.com/angular/angular-cli", - "dependencies": { - } -} diff --git a/packages/@angular-cli/base-href-webpack/src/base-href-webpack-plugin.spec.ts b/packages/@angular-cli/base-href-webpack/src/base-href-webpack-plugin.spec.ts deleted file mode 100644 index c0c2e320857c..000000000000 --- a/packages/@angular-cli/base-href-webpack/src/base-href-webpack-plugin.spec.ts +++ /dev/null @@ -1,66 +0,0 @@ -import {oneLineTrim} from 'common-tags'; -import {BaseHrefWebpackPlugin} from './base-href-webpack-plugin'; - - -function mockCompiler(indexHtml: string, callback: Function) { - return { - plugin: function (event: any, compilerCallback: Function) { - const compilation = { - plugin: function (hook: any, compilationCallback: Function) { - const htmlPluginData = { - html: indexHtml - }; - compilationCallback(htmlPluginData, callback); - } - }; - compilerCallback(compilation); - } - }; -} - -describe('base href webpack plugin', () => { - const html = oneLineTrim` - - - - - `; - - it('should do nothing when baseHref is null', () => { - const plugin = new BaseHrefWebpackPlugin({ baseHref: null }); - - const compiler = mockCompiler(html, (x: any, htmlPluginData: any) => { - expect(htmlPluginData.html).toEqual(''); - }); - plugin.apply(compiler); - }); - - it('should insert base tag when not exist', function () { - const plugin = new BaseHrefWebpackPlugin({ baseHref: '/' }); - const compiler = mockCompiler(html, (x: any, htmlPluginData: any) => { - expect(htmlPluginData.html).toEqual(oneLineTrim` - - - - - `); - }); - - plugin.apply(compiler); - }); - - it('should replace href attribute when base tag already exists', function () { - const plugin = new BaseHrefWebpackPlugin({ baseHref: '/myUrl/' }); - - const compiler = mockCompiler(oneLineTrim` - - - `, (x: any, htmlPluginData: any) => { - expect(htmlPluginData.html).toEqual(oneLineTrim` - - - `); - }); - plugin.apply(compiler); - }); -}); diff --git a/packages/@angular-cli/base-href-webpack/src/base-href-webpack-plugin.ts b/packages/@angular-cli/base-href-webpack/src/base-href-webpack-plugin.ts deleted file mode 100644 index c4b857b87022..000000000000 --- a/packages/@angular-cli/base-href-webpack/src/base-href-webpack-plugin.ts +++ /dev/null @@ -1,39 +0,0 @@ -export interface BaseHrefWebpackPluginOptions { - baseHref: string; -} - -export class BaseHrefWebpackPlugin { - constructor(private options: BaseHrefWebpackPluginOptions) { } - - apply(compiler: any): void { - // Ignore if baseHref is not passed - if (!this.options.baseHref) { - return; - } - - compiler.plugin('compilation', (compilation: any) => { - compilation.plugin( - 'html-webpack-plugin-before-html-processing', - (htmlPluginData: any, callback: Function) => { - // Check if base tag already exists - const baseTagRegex = //i; - const baseTagMatches = htmlPluginData.html.match(baseTagRegex); - if (!baseTagMatches) { - // Insert it in top of the head if not exist - htmlPluginData.html = htmlPluginData.html.replace( - //i, '$&' + `` - ); - } else { - // Replace only href attribute if exists - const modifiedBaseTag = baseTagMatches[0].replace( - /href="\S+"/i, `href="${this.options.baseHref}"` - ); - htmlPluginData.html = htmlPluginData.html.replace(baseTagRegex, modifiedBaseTag); - } - - callback(null, htmlPluginData); - } - ); - }); - } -} diff --git a/packages/@angular-cli/base-href-webpack/src/index.ts b/packages/@angular-cli/base-href-webpack/src/index.ts deleted file mode 100644 index 3140fa868c4e..000000000000 --- a/packages/@angular-cli/base-href-webpack/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ - -export * from './base-href-webpack-plugin'; diff --git a/packages/@angular-cli/base-href-webpack/tsconfig.json b/packages/@angular-cli/base-href-webpack/tsconfig.json deleted file mode 100644 index 2a2f0f7c6e59..000000000000 --- a/packages/@angular-cli/base-href-webpack/tsconfig.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "compilerOptions": { - "declaration": true, - "experimentalDecorators": true, - "mapRoot": "", - "module": "commonjs", - "moduleResolution": "node", - "noEmitOnError": true, - "noImplicitAny": true, - "outDir": "../../../dist/@angular-cli/base-href-webpack", - "rootDir": ".", - "sourceMap": true, - "sourceRoot": "/", - "target": "es5", - "lib": ["es6"], - "typeRoots": [ - "../../node_modules/@types" - ], - "types": [ - "jasmine", - "node" - ] - }, - "exclude": [ - "**/*.spec.ts" - ] -} diff --git a/packages/@ngtools/webpack/README.md b/packages/@ngtools/webpack/README.md deleted file mode 100644 index 2bd1e5abb5a2..000000000000 --- a/packages/@ngtools/webpack/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# Angular Ahead-of-Time Webpack Plugin - -Webpack plugin that AoT compiles your Angular components and modules. - -## Usage -In your webpack config, add the following plugin and loader: - -```typescript -import {AotPlugin} from '@ngtools/webpack' - -exports = { /* ... */ - module: { - rules: [ - { - test: /\.ts$/, - loader: '@ngtools/webpack', - } - ] - }, - - plugins: [ - new AotPlugin({ - tsConfigPath: 'path/to/tsconfig.json', - entryModule: 'path/to/app.module#AppModule' - }) - ] -} -``` - -The loader works with the webpack plugin to compile your TypeScript. It's important to include both, and to not include any other TypeScript compiler loader. - -## Options - -* `tsConfigPath`. The path to the `tsconfig.json` file. This is required. In your `tsconfig.json`, you can pass options to the Angular Compiler with `angularCompilerOptions`. -* `basePath`. Optional. The root to use by the compiler to resolve file paths. By default, use the `tsConfigPath` root. -* `entryModule`. Optional if specified in `angularCompilerOptions`. The path and classname of the main application module. This follows the format `path/to/file#ClassName`. -* `mainPath`. Optional if `entryModule` is specified. The `main.ts` file containing the bootstrap code. The plugin will use AST to determine the `entryModule`. -* `skipCodeGeneration`. Optional, defaults to false. Disable code generation and do not refactor the code to bootstrap. This replaces `templateUrl: "string"` with `template: require("string")` (and similar for styles) to allow for webpack to properly link the resources. -* `typeChecking`. Optional, defaults to true. Enable type checking through your application. This will slow down compilation, but show syntactic and semantic errors in webpack. diff --git a/packages/@ngtools/webpack/package.json b/packages/@ngtools/webpack/package.json deleted file mode 100644 index ff47bf556c1f..000000000000 --- a/packages/@ngtools/webpack/package.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "@ngtools/webpack", - "version": "1.2.1", - "description": "Webpack plugin that AoT compiles your Angular components and modules.", - "main": "./src/index.js", - "typings": "src/index.d.ts", - "license": "MIT", - "keywords": [ - "angular", - "webpack", - "plugin", - "aot" - ], - "repository": { - "type": "git", - "url": "https://github.com/angular/angular-cli.git" - }, - "author": "angular", - "bugs": { - "url": "https://github.com/angular/angular-cli/issues" - }, - "homepage": "https://github.com/angular/angular-cli/tree/master/packages/webpack", - "engines": { - "node": ">= 4.1.0", - "npm": ">= 3.0.0" - }, - "dependencies": { - "enhanced-resolve": "^2.3.0", - "loader-utils": "^0.2.16", - "magic-string": "^0.16.0", - "source-map": "^0.5.6" - }, - "peerDependencies": { - "@angular/compiler": "^2.3.1", - "@angular/compiler-cli": "^2.3.1", - "@angular/core": "^2.3.1", - "@angular/tsc-wrapped": "^0.5.0", - "typescript": "^2.0.2", - "reflect-metadata": "^0.1.8", - "webpack": "^2.1.0-beta.25" - } -} diff --git a/packages/@ngtools/webpack/src/compiler_host.ts b/packages/@ngtools/webpack/src/compiler_host.ts deleted file mode 100644 index b1ff333a8944..000000000000 --- a/packages/@ngtools/webpack/src/compiler_host.ts +++ /dev/null @@ -1,242 +0,0 @@ -import * as ts from 'typescript'; -import {basename, dirname, join} from 'path'; -import * as fs from 'fs'; - - -export interface OnErrorFn { - (message: string): void; -} - - -const dev = Math.floor(Math.random() * 10000); - - -export class VirtualStats implements fs.Stats { - protected _ctime = new Date(); - protected _mtime = new Date(); - protected _atime = new Date(); - protected _btime = new Date(); - protected _dev = dev; - protected _ino = Math.floor(Math.random() * 100000); - protected _mode = parseInt('777', 8); // RWX for everyone. - protected _uid = process.env['UID'] || 0; - protected _gid = process.env['GID'] || 0; - - constructor(protected _path: string) {} - - isFile() { return false; } - isDirectory() { return false; } - isBlockDevice() { return false; } - isCharacterDevice() { return false; } - isSymbolicLink() { return false; } - isFIFO() { return false; } - isSocket() { return false; } - - get dev() { return this._dev; } - get ino() { return this._ino; } - get mode() { return this._mode; } - get nlink() { return 1; } // Default to 1 hard link. - get uid() { return this._uid; } - get gid() { return this._gid; } - get rdev() { return 0; } - get size() { return 0; } - get blksize() { return 512; } - get blocks() { return Math.ceil(this.size / this.blksize); } - get atime() { return this._atime; } - get mtime() { return this._mtime; } - get ctime() { return this._ctime; } - get birthtime() { return this._btime; } -} - -export class VirtualDirStats extends VirtualStats { - constructor(_fileName: string) { - super(_fileName); - } - - isDirectory() { return true; } - - get size() { return 1024; } -} - -export class VirtualFileStats extends VirtualStats { - private _sourceFile: ts.SourceFile; - constructor(_fileName: string, private _content: string) { - super(_fileName); - } - - get content() { return this._content; } - set content(v: string) { - this._content = v; - this._mtime = new Date(); - } - getSourceFile(languageVersion: ts.ScriptTarget, setParentNodes: boolean) { - if (!this._sourceFile) { - this._sourceFile = ts.createSourceFile( - this._path, - this._content, - languageVersion, - setParentNodes); - } - - return this._sourceFile; - } - - isFile() { return true; } - - get size() { return this._content.length; } -} - - -export class WebpackCompilerHost implements ts.CompilerHost { - private _delegate: ts.CompilerHost; - private _files: {[path: string]: VirtualFileStats} = Object.create(null); - private _directories: {[path: string]: VirtualDirStats} = Object.create(null); - private _changed = false; - - private _basePath: string; - private _setParentNodes: boolean; - - constructor(private _options: ts.CompilerOptions, basePath: string) { - this._setParentNodes = true; - this._delegate = ts.createCompilerHost(this._options, this._setParentNodes); - this._basePath = this._normalizePath(basePath); - } - - private _normalizePath(path: string) { - return path.replace(/\\/g, '/'); - } - - private _resolve(path: string) { - path = this._normalizePath(path); - if (path[0] == '.') { - return join(this.getCurrentDirectory(), path); - } else if (path[0] == '/' || path.match(/^\w:\//)) { - return path; - } else { - return join(this._basePath, path); - } - } - - private _setFileContent(fileName: string, content: string) { - this._files[fileName] = new VirtualFileStats(fileName, content); - - let p = dirname(fileName); - while (p && !this._directories[p]) { - this._directories[p] = new VirtualDirStats(p); - p = dirname(p); - } - - this._changed = true; - } - - populateWebpackResolver(resolver: any) { - const fs = resolver.fileSystem; - if (!this._changed) { - return; - } - - const isWindows = process.platform.startsWith('win'); - for (const fileName of Object.keys(this._files)) { - const stats = this._files[fileName]; - // If we're on windows, we need to populate with the proper path separator. - const path = isWindows ? fileName.replace(/\//g, '\\') : fileName; - fs._statStorage.data[path] = [null, stats]; - fs._readFileStorage.data[path] = [null, stats.content]; - } - for (const dirName of Object.keys(this._directories)) { - const stats = this._directories[dirName]; - const dirs = this.getDirectories(dirName); - const files = this.getFiles(dirName); - // If we're on windows, we need to populate with the proper path separator. - const path = isWindows ? dirName.replace(/\//g, '\\') : dirName; - fs._statStorage.data[path] = [null, stats]; - fs._readdirStorage.data[path] = [null, files.concat(dirs)]; - } - - this._changed = false; - } - - fileExists(fileName: string): boolean { - fileName = this._resolve(fileName); - return fileName in this._files || this._delegate.fileExists(fileName); - } - - readFile(fileName: string): string { - fileName = this._resolve(fileName); - return (fileName in this._files) - ? this._files[fileName].content - : this._delegate.readFile(fileName); - } - - directoryExists(directoryName: string): boolean { - directoryName = this._resolve(directoryName); - return (directoryName in this._directories) || this._delegate.directoryExists(directoryName); - } - - getFiles(path: string): string[] { - path = this._resolve(path); - return Object.keys(this._files) - .filter(fileName => dirname(fileName) == path) - .map(path => basename(path)); - } - - getDirectories(path: string): string[] { - path = this._resolve(path); - const subdirs = Object.keys(this._directories) - .filter(fileName => dirname(fileName) == path) - .map(path => basename(path)); - - let delegated: string[]; - try { - delegated = this._delegate.getDirectories(path); - } catch (e) { - delegated = []; - } - return delegated.concat(subdirs); - } - - getSourceFile(fileName: string, languageVersion: ts.ScriptTarget, onError?: OnErrorFn) { - fileName = this._resolve(fileName); - - if (!(fileName in this._files)) { - return this._delegate.getSourceFile(fileName, languageVersion, onError); - } - - return this._files[fileName].getSourceFile(languageVersion, this._setParentNodes); - } - - getCancellationToken() { - return this._delegate.getCancellationToken(); - } - - getDefaultLibFileName(options: ts.CompilerOptions) { - return this._delegate.getDefaultLibFileName(options); - } - - // This is due to typescript CompilerHost interface being weird on writeFile. This shuts down - // typings in WebStorm. - get writeFile() { - return (fileName: string, data: string, writeByteOrderMark: boolean, - onError?: (message: string) => void, sourceFiles?: ts.SourceFile[]): void => { - fileName = this._resolve(fileName); - this._setFileContent(fileName, data); - }; - } - - getCurrentDirectory(): string { - return this._basePath !== null ? this._basePath : this._delegate.getCurrentDirectory(); - } - - getCanonicalFileName(fileName: string): string { - fileName = this._resolve(fileName); - return this._delegate.getCanonicalFileName(fileName); - } - - useCaseSensitiveFileNames(): boolean { - return this._delegate.useCaseSensitiveFileNames(); - } - - getNewLine(): string { - return this._delegate.getNewLine(); - } -} diff --git a/packages/@ngtools/webpack/src/entry_resolver.ts b/packages/@ngtools/webpack/src/entry_resolver.ts deleted file mode 100644 index 1a51069f48e3..000000000000 --- a/packages/@ngtools/webpack/src/entry_resolver.ts +++ /dev/null @@ -1,160 +0,0 @@ -import * as fs from 'fs'; -import {join} from 'path'; -import * as ts from 'typescript'; - -import {TypeScriptFileRefactor} from './refactor'; - - -function _recursiveSymbolExportLookup(refactor: TypeScriptFileRefactor, - symbolName: string, - host: ts.CompilerHost, - program: ts.Program): string | null { - // Check this file. - const hasSymbol = refactor.findAstNodes(null, ts.SyntaxKind.ClassDeclaration) - .some((cd: ts.ClassDeclaration) => { - return cd.name && cd.name.text == symbolName; - }); - if (hasSymbol) { - return refactor.fileName; - } - - // We found the bootstrap variable, now we just need to get where it's imported. - const exports = refactor.findAstNodes(null, ts.SyntaxKind.ExportDeclaration) - .map(node => node as ts.ExportDeclaration); - - for (const decl of exports) { - if (!decl.moduleSpecifier || decl.moduleSpecifier.kind !== ts.SyntaxKind.StringLiteral) { - continue; - } - - const modulePath = (decl.moduleSpecifier as ts.StringLiteral).text; - const resolvedModule = ts.resolveModuleName( - modulePath, refactor.fileName, program.getCompilerOptions(), host); - if (!resolvedModule.resolvedModule || !resolvedModule.resolvedModule.resolvedFileName) { - return null; - } - - const module = resolvedModule.resolvedModule.resolvedFileName; - if (!decl.exportClause) { - const moduleRefactor = new TypeScriptFileRefactor(module, host, program); - const maybeModule = _recursiveSymbolExportLookup(moduleRefactor, symbolName, host, program); - if (maybeModule) { - return maybeModule; - } - continue; - } - - const binding = decl.exportClause as ts.NamedExports; - for (const specifier of binding.elements) { - if (specifier.name.text == symbolName) { - // If it's a directory, load its index and recursively lookup. - if (fs.statSync(module).isDirectory()) { - const indexModule = join(module, 'index.ts'); - if (fs.existsSync(indexModule)) { - const indexRefactor = new TypeScriptFileRefactor(indexModule, host, program); - const maybeModule = _recursiveSymbolExportLookup( - indexRefactor, symbolName, host, program); - if (maybeModule) { - return maybeModule; - } - } - } - - // Create the source and verify that the symbol is at least a class. - const source = new TypeScriptFileRefactor(module, host, program); - const hasSymbol = source.findAstNodes(null, ts.SyntaxKind.ClassDeclaration) - .some((cd: ts.ClassDeclaration) => { - return cd.name && cd.name.text == symbolName; - }); - - if (hasSymbol) { - return module; - } - } - } - } - - return null; -} - -function _symbolImportLookup(refactor: TypeScriptFileRefactor, - symbolName: string, - host: ts.CompilerHost, - program: ts.Program): string | null { - // We found the bootstrap variable, now we just need to get where it's imported. - const imports = refactor.findAstNodes(null, ts.SyntaxKind.ImportDeclaration) - .map(node => node as ts.ImportDeclaration); - - for (const decl of imports) { - if (!decl.importClause || !decl.moduleSpecifier) { - continue; - } - if (decl.moduleSpecifier.kind !== ts.SyntaxKind.StringLiteral) { - continue; - } - - const resolvedModule = ts.resolveModuleName( - (decl.moduleSpecifier as ts.StringLiteral).text, - refactor.fileName, program.getCompilerOptions(), host); - if (!resolvedModule.resolvedModule || !resolvedModule.resolvedModule.resolvedFileName) { - return null; - } - - const module = resolvedModule.resolvedModule.resolvedFileName; - if (decl.importClause.namedBindings.kind == ts.SyntaxKind.NamespaceImport) { - const binding = decl.importClause.namedBindings as ts.NamespaceImport; - if (binding.name.text == symbolName) { - // This is a default export. - return module; - } - } else if (decl.importClause.namedBindings.kind == ts.SyntaxKind.NamedImports) { - const binding = decl.importClause.namedBindings as ts.NamedImports; - for (const specifier of binding.elements) { - if (specifier.name.text == symbolName) { - // Create the source and recursively lookup the import. - const source = new TypeScriptFileRefactor(module, host, program); - const maybeModule = _recursiveSymbolExportLookup(source, symbolName, host, program); - if (maybeModule) { - return maybeModule; - } - } - } - } - } - return null; -} - - -export function resolveEntryModuleFromMain(mainPath: string, - host: ts.CompilerHost, - program: ts.Program) { - const source = new TypeScriptFileRefactor(mainPath, host, program); - - const bootstrap = source.findAstNodes(source.sourceFile, ts.SyntaxKind.CallExpression, false) - .map(node => node as ts.CallExpression) - .filter(call => { - const access = call.expression as ts.PropertyAccessExpression; - return access.kind == ts.SyntaxKind.PropertyAccessExpression - && access.name.kind == ts.SyntaxKind.Identifier - && (access.name.text == 'bootstrapModule' - || access.name.text == 'bootstrapModuleFactory'); - }) - .map(node => node.arguments[0] as ts.Identifier) - .filter(node => node.kind == ts.SyntaxKind.Identifier); - - if (bootstrap.length != 1) { - throw new Error('Tried to find bootstrap code, but could not. Specify either ' - + 'statically analyzable bootstrap code or pass in an entryModule ' - + 'to the plugins options.'); - } - const bootstrapSymbolName = bootstrap[0].text; - const module = _symbolImportLookup(source, bootstrapSymbolName, host, program); - if (module) { - return `${module.replace(/\.ts$/, '')}#${bootstrapSymbolName}`; - } - - // shrug... something bad happened and we couldn't find the import statement. - throw new Error('Tried to find bootstrap code, but could not. Specify either ' - + 'statically analyzable bootstrap code or pass in an entryModule ' - + 'to the plugins options.'); -} diff --git a/packages/@ngtools/webpack/src/index.ts b/packages/@ngtools/webpack/src/index.ts deleted file mode 100644 index 067d1e724697..000000000000 --- a/packages/@ngtools/webpack/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import 'reflect-metadata'; - -export * from './plugin'; -export {ngcLoader as default} from './loader'; -export {PathsPlugin} from './paths-plugin'; diff --git a/packages/@ngtools/webpack/src/loader.ts b/packages/@ngtools/webpack/src/loader.ts deleted file mode 100644 index 8f8b84957dff..000000000000 --- a/packages/@ngtools/webpack/src/loader.ts +++ /dev/null @@ -1,234 +0,0 @@ -import * as path from 'path'; -import * as ts from 'typescript'; -import {AotPlugin} from './plugin'; -import {TypeScriptFileRefactor} from './refactor'; - -const loaderUtils = require('loader-utils'); - - -function _getContentOfKeyLiteral(source: ts.SourceFile, node: ts.Node): string { - if (node.kind == ts.SyntaxKind.Identifier) { - return (node as ts.Identifier).text; - } else if (node.kind == ts.SyntaxKind.StringLiteral) { - return (node as ts.StringLiteral).text; - } else { - return null; - } -} - -function _removeDecorators(refactor: TypeScriptFileRefactor) { - // TODO: replace this by tsickle. - // Find all decorators. - // refactor.findAstNodes(refactor.sourceFile, ts.SyntaxKind.Decorator) - // .forEach(d => refactor.removeNode(d)); -} - - -function _replaceBootstrap(plugin: AotPlugin, refactor: TypeScriptFileRefactor) { - // If bootstrapModule can't be found, bail out early. - if (!refactor.sourceMatch(/\bbootstrapModule\b/)) { - return; - } - - // Calculate the base path. - const basePath = path.normalize(plugin.basePath); - const genDir = path.normalize(plugin.genDir); - const dirName = path.normalize(path.dirname(refactor.fileName)); - const entryModule = plugin.entryModule; - const entryModuleFileName = path.normalize(entryModule.path + '.ngfactory'); - const relativeEntryModulePath = path.relative(basePath, entryModuleFileName); - const fullEntryModulePath = path.resolve(genDir, relativeEntryModulePath); - const relativeNgFactoryPath = path.relative(dirName, fullEntryModulePath); - const ngFactoryPath = './' + relativeNgFactoryPath.replace(/\\/g, '/'); - - const allCalls = refactor.findAstNodes(refactor.sourceFile, - ts.SyntaxKind.CallExpression, true) as ts.CallExpression[]; - - const bootstraps = allCalls - .filter(call => call.expression.kind == ts.SyntaxKind.PropertyAccessExpression) - .map(call => call.expression as ts.PropertyAccessExpression) - .filter(access => { - return access.name.kind == ts.SyntaxKind.Identifier - && access.name.text == 'bootstrapModule'; - }); - - const calls: ts.CallExpression[] = bootstraps - .reduce((previous, access) => { - const expressions - = refactor.findAstNodes(access, ts.SyntaxKind.CallExpression, true) as ts.CallExpression[]; - return previous.concat(expressions); - }, []) - .filter((call: ts.CallExpression) => { - return call.expression.kind == ts.SyntaxKind.Identifier - && (call.expression as ts.Identifier).text == 'platformBrowserDynamic'; - }); - - if (calls.length == 0) { - // Didn't find any dynamic bootstrapping going on. - return; - } - - // Create the changes we need. - allCalls - .filter(call => bootstraps.some(bs => bs == call.expression)) - .forEach((call: ts.CallExpression) => { - refactor.replaceNode(call.arguments[0], entryModule.className + 'NgFactory'); - }); - - calls.forEach(call => refactor.replaceNode(call.expression, 'platformBrowser')); - - bootstraps - .forEach((bs: ts.PropertyAccessExpression) => { - // This changes the call. - refactor.replaceNode(bs.name, 'bootstrapModuleFactory'); - }); - - refactor.insertImport('platformBrowser', '@angular/platform-browser'); - refactor.insertImport(entryModule.className + 'NgFactory', ngFactoryPath); -} - -export function removeModuleIdOnlyForTesting(refactor: TypeScriptFileRefactor) { - _removeModuleId(refactor); -} - -function _removeModuleId(refactor: TypeScriptFileRefactor) { - const sourceFile = refactor.sourceFile; - - refactor.findAstNodes(sourceFile, ts.SyntaxKind.ObjectLiteralExpression, true) - // Get all their property assignments. - .filter((node: ts.ObjectLiteralExpression) => - node.properties.some(prop => prop.name.getText() == 'moduleId')) - .forEach((node: ts.ObjectLiteralExpression) => { - const moduleIdProp = node.properties.filter((prop: ts.ObjectLiteralElement, idx: number) => { - return prop.name.getText() == 'moduleId'; - })[0]; - // get the trailing comma - const moduleIdCommaProp = moduleIdProp.parent.getChildAt(1).getChildren()[1]; - refactor.removeNodes(moduleIdProp, moduleIdCommaProp); - }); -} - -function _replaceResources(refactor: TypeScriptFileRefactor): void { - const sourceFile = refactor.sourceFile; - - // Find all object literals. - refactor.findAstNodes(sourceFile, ts.SyntaxKind.ObjectLiteralExpression, true) - // Get all their property assignments. - .map(node => refactor.findAstNodes(node, ts.SyntaxKind.PropertyAssignment)) - // Flatten into a single array (from an array of array). - .reduce((prev, curr) => curr ? prev.concat(curr) : prev, []) - // Remove every property assignment that aren't 'loadChildren'. - .filter((node: ts.PropertyAssignment) => { - const key = _getContentOfKeyLiteral(sourceFile, node.name); - if (!key) { - // key is an expression, can't do anything. - return false; - } - return key == 'templateUrl' || key == 'styleUrls'; - }) - // Get the full text of the initializer. - .forEach((node: ts.PropertyAssignment) => { - const key = _getContentOfKeyLiteral(sourceFile, node.name); - - if (key == 'templateUrl') { - refactor.replaceNode(node, - `template: require(${node.initializer.getFullText(sourceFile)})`); - } else if (key == 'styleUrls') { - const arr = ( - refactor.findAstNodes(node, ts.SyntaxKind.ArrayLiteralExpression, false)); - if (!arr || arr.length == 0 || arr[0].elements.length == 0) { - return; - } - - const initializer = arr[0].elements.map((element: ts.Expression) => { - return element.getFullText(sourceFile); - }); - refactor.replaceNode(node, `styles: [require(${initializer.join('), require(')})]`); - } - }); -} - - -function _checkDiagnostics(refactor: TypeScriptFileRefactor) { - const diagnostics = refactor.getDiagnostics(); - - if (diagnostics.length > 0) { - const message = diagnostics - .map(diagnostic => { - const {line, character} = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start); - const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'); - return `${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message})`; - }) - .join('\n'); - throw new Error(message); - } -} - - -// Super simple TS transpiler loader for testing / isolated usage. does not type check! -export function ngcLoader(source: string) { - this.cacheable(); - const cb: any = this.async(); - const sourceFileName: string = this.resourcePath; - - const plugin = this._compilation._ngToolsWebpackPluginInstance as AotPlugin; - // We must verify that AotPlugin is an instance of the right class. - if (plugin && plugin instanceof AotPlugin) { - const refactor = new TypeScriptFileRefactor( - sourceFileName, plugin.compilerHost, plugin.program); - - Promise.resolve() - .then(() => { - if (!plugin.skipCodeGeneration) { - return Promise.resolve() - .then(() => _removeDecorators(refactor)) - .then(() => _replaceBootstrap(plugin, refactor)); - } else { - return Promise.resolve() - .then(() => _replaceResources(refactor)) - .then(() => _removeModuleId(refactor)); - } - }) - .then(() => { - if (plugin.typeCheck) { - _checkDiagnostics(refactor); - } - }) - .then(() => { - // Force a few compiler options to make sure we get the result we want. - const compilerOptions: ts.CompilerOptions = Object.assign({}, plugin.compilerOptions, { - inlineSources: true, - inlineSourceMap: false, - sourceRoot: plugin.basePath - }); - - const result = refactor.transpile(compilerOptions); - cb(null, result.outputText, result.sourceMap); - }) - .catch(err => cb(err)); - } else { - const options = loaderUtils.parseQuery(this.query); - const tsConfigPath = options.tsConfigPath; - const tsConfig = ts.readConfigFile(tsConfigPath, ts.sys.readFile); - - if (tsConfig.error) { - throw tsConfig.error; - } - - const compilerOptions: ts.CompilerOptions = tsConfig.config.compilerOptions; - for (const key of Object.keys(options)) { - if (key == 'tsConfigPath') { - continue; - } - compilerOptions[key] = options[key]; - } - const compilerHost = ts.createCompilerHost(compilerOptions); - const refactor = new TypeScriptFileRefactor(sourceFileName, compilerHost); - _replaceResources(refactor); - - const result = refactor.transpile(compilerOptions); - // Webpack is going to take care of this. - result.outputText = result.outputText.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, ''); - cb(null, result.outputText, result.sourceMap); - } -} diff --git a/packages/@ngtools/webpack/src/paths-plugin.ts b/packages/@ngtools/webpack/src/paths-plugin.ts deleted file mode 100644 index b4ec576d462d..000000000000 --- a/packages/@ngtools/webpack/src/paths-plugin.ts +++ /dev/null @@ -1,174 +0,0 @@ -import * as path from 'path'; -import * as ts from 'typescript'; -import {Request, ResolverPlugin, Callback, Tapable} from './webpack'; - - -const ModulesInRootPlugin: new (a: string, b: string, c: string) => ResolverPlugin - = require('enhanced-resolve/lib/ModulesInRootPlugin'); - -interface CreateInnerCallback { - (callback: Callback, - options: Callback, - message?: string, - messageOptional?: string): Callback; -} - -const createInnerCallback: CreateInnerCallback - = require('enhanced-resolve/lib/createInnerCallback'); -const getInnerRequest: (resolver: ResolverPlugin, request: Request) => string - = require('enhanced-resolve/lib/getInnerRequest'); - - -function escapeRegExp(str: string): string { - return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&'); -} - - -export interface PathsPluginOptions { - tsConfigPath: string; - compilerOptions?: ts.CompilerOptions; - compilerHost?: ts.CompilerHost; -} - -export class PathsPlugin implements Tapable { - private _tsConfigPath: string; - private _compilerOptions: ts.CompilerOptions; - private _host: ts.CompilerHost; - - source: string; - target: string; - - private mappings: any; - - private _absoluteBaseUrl: string; - - private static _loadOptionsFromTsConfig(tsConfigPath: string, host?: ts.CompilerHost): - ts.CompilerOptions { - const tsConfig = ts.readConfigFile(tsConfigPath, (path: string) => { - if (host) { - return host.readFile(path); - } else { - return ts.sys.readFile(path); - } - }); - if (tsConfig.error) { - throw tsConfig.error; - } - return tsConfig.config; - } - - constructor(options: PathsPluginOptions) { - if (!options.hasOwnProperty('tsConfigPath')) { - // This could happen in JavaScript. - throw new Error('tsConfigPath option is mandatory.'); - } - this._tsConfigPath = options.tsConfigPath; - - if (options.hasOwnProperty('compilerOptions')) { - this._compilerOptions = Object.assign({}, options.compilerOptions); - } else { - this._compilerOptions = PathsPlugin._loadOptionsFromTsConfig(this._tsConfigPath, null); - } - - if (options.hasOwnProperty('compilerHost')) { - this._host = options.compilerHost; - } else { - this._host = ts.createCompilerHost(this._compilerOptions, false); - } - - this.source = 'described-resolve'; - this.target = 'resolve'; - - this._absoluteBaseUrl = path.resolve( - path.dirname(this._tsConfigPath), - this._compilerOptions.baseUrl || '.' - ); - - this.mappings = []; - let paths = this._compilerOptions.paths || {}; - Object.keys(paths).forEach(alias => { - let onlyModule = alias.indexOf('*') === -1; - let excapedAlias = escapeRegExp(alias); - let targets = paths[alias]; - targets.forEach(target => { - let aliasPattern: RegExp; - if (onlyModule) { - aliasPattern = new RegExp(`^${excapedAlias}$`); - } else { - let withStarCapturing = excapedAlias.replace('\\*', '(.*)'); - aliasPattern = new RegExp(`^${withStarCapturing}`); - } - - this.mappings.push({ - onlyModule, - alias, - aliasPattern, - target: target - }); - }); - }); - } - - apply(resolver: ResolverPlugin): void { - let { baseUrl } = this._compilerOptions; - - if (baseUrl) { - resolver.apply(new ModulesInRootPlugin('module', this._absoluteBaseUrl, 'resolve')); - } - - this.mappings.forEach((mapping: any) => { - resolver.plugin(this.source, this.createPlugin(resolver, mapping)); - }); - } - - resolve(resolver: ResolverPlugin, mapping: any, request: any, callback: Callback): any { - let innerRequest = getInnerRequest(resolver, request); - if (!innerRequest) { - return callback(); - } - - let match = innerRequest.match(mapping.aliasPattern); - if (!match) { - return callback(); - } - - let newRequestStr = mapping.target; - if (!mapping.onlyModule) { - newRequestStr = newRequestStr.replace('*', match[1]); - } - if (newRequestStr[0] === '.') { - newRequestStr = path.resolve(this._absoluteBaseUrl, newRequestStr); - } - - let newRequest = Object.assign({}, request, { - request: newRequestStr - }) as Request; - - return resolver.doResolve( - this.target, - newRequest, - `aliased with mapping '${innerRequest}': '${mapping.alias}' to '${newRequestStr}'`, - createInnerCallback( - function(err, result) { - if (arguments.length > 0) { - return callback(err, result); - } - - // don't allow other aliasing or raw request - callback(null, null); - }, - callback - ) - ); - } - - createPlugin(resolver: ResolverPlugin, mapping: any): any { - return (request: any, callback: Callback) => { - try { - this.resolve(resolver, mapping, request, callback); - } catch (err) { - callback(err); - } - }; - } -} diff --git a/packages/@ngtools/webpack/src/plugin.ts b/packages/@ngtools/webpack/src/plugin.ts deleted file mode 100644 index b01e8f910163..000000000000 --- a/packages/@ngtools/webpack/src/plugin.ts +++ /dev/null @@ -1,328 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; -import * as ts from 'typescript'; - -import {__NGTOOLS_PRIVATE_API_2} from '@angular/compiler-cli'; -import {AngularCompilerOptions} from '@angular/tsc-wrapped'; - -import {WebpackResourceLoader} from './resource_loader'; -import {createResolveDependenciesFromContextMap} from './utils'; -import {WebpackCompilerHost} from './compiler_host'; -import {resolveEntryModuleFromMain} from './entry_resolver'; -import {Tapable} from './webpack'; -import {PathsPlugin} from './paths-plugin'; - - -/** - * Option Constants - */ -export interface AotPluginOptions { - tsConfigPath: string; - basePath?: string; - entryModule?: string; - mainPath?: string; - typeChecking?: boolean; - skipCodeGeneration?: boolean; - i18nFile?: string; - i18nFormat?: string; - locale?: string; - - // Use tsconfig to include path globs. - exclude?: string | string[]; -} - - -export class AotPlugin implements Tapable { - private _compilerOptions: ts.CompilerOptions; - private _angularCompilerOptions: AngularCompilerOptions; - private _program: ts.Program; - private _rootFilePath: string[]; - private _compilerHost: WebpackCompilerHost; - private _resourceLoader: WebpackResourceLoader; - private _lazyRoutes: { [route: string]: string }; - private _tsConfigPath: string; - private _entryModule: string; - - private _donePromise: Promise; - private _compiler: any = null; - private _compilation: any = null; - - private _typeCheck: boolean = true; - private _skipCodeGeneration: boolean = false; - private _basePath: string; - private _genDir: string; - - private _i18nFile: string; - private _i18nFormat: string; - private _locale: string; - - constructor(options: AotPluginOptions) { - this._setupOptions(options); - } - - get basePath() { return this._basePath; } - get compilation() { return this._compilation; } - get compilerHost() { return this._compilerHost; } - get compilerOptions() { return this._compilerOptions; } - get done() { return this._donePromise; } - get entryModule() { - const splitted = this._entryModule.split('#'); - const path = splitted[0]; - const className = splitted[1] || 'default'; - return {path, className}; - } - get genDir() { return this._genDir; } - get program() { return this._program; } - get skipCodeGeneration() { return this._skipCodeGeneration; } - get typeCheck() { return this._typeCheck; } - get i18nFile() { return this._i18nFile; } - get i18nFormat() { return this._i18nFormat; } - get locale() { return this._locale; } - - private _setupOptions(options: AotPluginOptions) { - // Fill in the missing options. - if (!options.hasOwnProperty('tsConfigPath')) { - throw new Error('Must specify "tsConfigPath" in the configuration of @ngtools/webpack.'); - } - this._tsConfigPath = options.tsConfigPath; - - // Check the base path. - const maybeBasePath = path.resolve(process.cwd(), this._tsConfigPath); - let basePath = maybeBasePath; - if (fs.statSync(maybeBasePath).isFile()) { - basePath = path.dirname(basePath); - } - if (options.hasOwnProperty('basePath')) { - basePath = path.resolve(process.cwd(), options.basePath); - } - - let tsConfigJson: any = null; - try { - tsConfigJson = JSON.parse(fs.readFileSync(this._tsConfigPath, 'utf8')); - } catch (err) { - throw new Error(`An error happened while parsing ${this._tsConfigPath} JSON: ${err}.`); - } - const tsConfig = ts.parseJsonConfigFileContent( - tsConfigJson, ts.sys, basePath, null, this._tsConfigPath); - - let fileNames = tsConfig.fileNames; - if (options.hasOwnProperty('exclude')) { - let exclude: string[] = typeof options.exclude == 'string' - ? [options.exclude as string] : (options.exclude as string[]); - - exclude.forEach((pattern: string) => { - const basePathPattern = '(' + basePath.replace(/\\/g, '/') - .replace(/[\-\[\]\/{}()+?.\\^$|*]/g, '\\$&') + ')?'; - pattern = pattern - // Replace windows path separators with forward slashes. - .replace(/\\/g, '/') - // Escape characters that are used normally in regexes, except stars. - .replace(/[\-\[\]{}()+?.\\^$|]/g, '\\$&') - // Two stars replacement. - .replace(/\*\*/g, '(?:.*)') - // One star replacement. - .replace(/\*/g, '(?:[^/]*)') - // Escape characters from the basePath and make sure it's forward slashes. - .replace(/^/, basePathPattern); - - const re = new RegExp('^' + pattern + '$'); - fileNames = fileNames.filter(x => !x.replace(/\\/g, '/').match(re)); - }); - } else { - fileNames = fileNames.filter(fileName => !/\.spec\.ts$/.test(fileName)); - } - this._rootFilePath = fileNames; - - // Check the genDir. We generate a default gendir that's under basepath; it will generate - // a `node_modules` directory and because of that we don't want TypeScript resolution to - // resolve to that directory but the real `node_modules`. - let genDir = path.join(basePath, '$$_gendir'); - - this._compilerOptions = tsConfig.options; - this._angularCompilerOptions = Object.assign( - { genDir }, - this._compilerOptions, - tsConfig.raw['angularCompilerOptions'], - { basePath } - ); - - if (this._angularCompilerOptions.hasOwnProperty('genDir')) { - genDir = path.resolve(basePath, this._angularCompilerOptions.genDir); - this._angularCompilerOptions.genDir = genDir; - } - - this._basePath = basePath; - this._genDir = genDir; - - if (options.hasOwnProperty('typeChecking')) { - this._typeCheck = options.typeChecking; - } - if (options.hasOwnProperty('skipCodeGeneration')) { - this._skipCodeGeneration = options.skipCodeGeneration; - } - - this._compilerHost = new WebpackCompilerHost(this._compilerOptions, this._basePath); - this._program = ts.createProgram( - this._rootFilePath, this._compilerOptions, this._compilerHost); - - if (options.entryModule) { - this._entryModule = options.entryModule; - } else { - if (options.mainPath) { - this._entryModule = resolveEntryModuleFromMain(options.mainPath, this._compilerHost, - this._program); - } else { - this._entryModule = (tsConfig.raw['angularCompilerOptions'] as any).entryModule; - } - } - - if (options.hasOwnProperty('i18nFile')) { - this._i18nFile = options.i18nFile; - } - if (options.hasOwnProperty('i18nFormat')) { - this._i18nFormat = options.i18nFormat; - } - if (options.hasOwnProperty('locale')) { - this._locale = options.locale; - } - } - - // registration hook for webpack plugin - apply(compiler: any) { - this._compiler = compiler; - - compiler.plugin('context-module-factory', (cmf: any) => { - cmf.plugin('before-resolve', (request: any, callback: (err?: any, request?: any) => void) => { - if (!request) { - return callback(); - } - - request.request = this.skipCodeGeneration ? this.basePath : this.genDir; - request.recursive = true; - request.dependencies.forEach((d: any) => d.critical = false); - return callback(null, request); - }); - cmf.plugin('after-resolve', (result: any, callback: (err?: any, request?: any) => void) => { - if (!result) { - return callback(); - } - - this.done.then(() => { - result.resource = this.skipCodeGeneration ? this.basePath : this.genDir; - result.recursive = true; - result.dependencies.forEach((d: any) => d.critical = false); - result.resolveDependencies = createResolveDependenciesFromContextMap( - (_: any, cb: any) => cb(null, this._lazyRoutes)); - - return callback(null, result); - }, () => callback(null)) - .catch(err => callback(err)); - }); - }); - - compiler.plugin('make', (compilation: any, cb: any) => this._make(compilation, cb)); - compiler.plugin('after-emit', (compilation: any, cb: any) => { - this._donePromise = null; - this._compilation = null; - compilation._ngToolsWebpackPluginInstance = null; - cb(); - }); - - // Virtual file system. - compiler.resolvers.normal.plugin('resolve', (request: any, cb?: (err?: any) => void) => { - if (request.request.match(/\.ts$/)) { - this.done.then(() => cb(), () => cb()); - } else { - cb(); - } - }); - compiler.resolvers.normal.apply(new PathsPlugin({ - tsConfigPath: this._tsConfigPath, - compilerOptions: this._compilerOptions, - compilerHost: this._compilerHost - })); - } - - private _make(compilation: any, cb: (err?: any, request?: any) => void) { - this._compilation = compilation; - if (this._compilation._ngToolsWebpackPluginInstance) { - return cb(new Error('An @ngtools/webpack plugin already exist for this compilation.')); - } - this._compilation._ngToolsWebpackPluginInstance = this; - - this._resourceLoader = new WebpackResourceLoader(compilation); - - this._donePromise = Promise.resolve() - .then(() => { - if (this._skipCodeGeneration) { - return; - } - - // Create the Code Generator. - return __NGTOOLS_PRIVATE_API_2.codeGen({ - basePath: this._basePath, - compilerOptions: this._compilerOptions, - program: this._program, - host: this._compilerHost, - angularCompilerOptions: this._angularCompilerOptions, - i18nFile: this.i18nFile, - i18nFormat: this.i18nFormat, - locale: this.locale, - - readResource: (path: string) => this._resourceLoader.get(path) - }); - }) - .then(() => { - // Create a new Program, based on the old one. This will trigger a resolution of all - // transitive modules, which include files that might just have been generated. - // This needs to happen after the code generator has been created for generated files - // to be properly resolved. - this._program = ts.createProgram( - this._rootFilePath, this._compilerOptions, this._compilerHost, this._program); - }) - .then(() => { - const diagnostics = this._program.getGlobalDiagnostics(); - if (diagnostics.length > 0) { - const message = diagnostics - .map(diagnostic => { - const {line, character} = diagnostic.file.getLineAndCharacterOfPosition( - diagnostic.start); - const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'); - return `${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message})`; - }) - .join('\n'); - - throw new Error(message); - } - }) - .then(() => { - // Populate the file system cache with the virtual module. - this._compilerHost.populateWebpackResolver(this._compiler.resolvers.normal); - }) - .then(() => { - // Process the lazy routes - this._lazyRoutes = {}; - const allLazyRoutes = __NGTOOLS_PRIVATE_API_2.listLazyRoutes({ - program: this._program, - host: this._compilerHost, - angularCompilerOptions: this._angularCompilerOptions, - entryModule: this._entryModule - }); - Object.keys(allLazyRoutes) - .forEach(k => { - const lazyRoute = allLazyRoutes[k]; - k = k.split('#')[0]; - if (this.skipCodeGeneration) { - this._lazyRoutes[k] = lazyRoute; - } else { - const lr = path.relative(this.basePath, lazyRoute.replace(/\.ts$/, '.ngfactory.ts')); - this._lazyRoutes[k + '.ngfactory'] = path.join(this.genDir, lr); - } - }); - }) - .then(() => cb(), (err: any) => { - compilation.errors.push(err); - cb(); - }); - } -} diff --git a/packages/@ngtools/webpack/src/refactor.ts b/packages/@ngtools/webpack/src/refactor.ts deleted file mode 100644 index 276c807a7fdd..000000000000 --- a/packages/@ngtools/webpack/src/refactor.ts +++ /dev/null @@ -1,232 +0,0 @@ -// TODO: move this in its own package. -import * as path from 'path'; -import * as ts from 'typescript'; -import {SourceMapConsumer, SourceMapGenerator} from 'source-map'; - -const MagicString = require('magic-string'); - - -export interface TranspileOutput { - outputText: string; - sourceMap: any | null; -} - - -function resolve(filePath: string, host: ts.CompilerHost, program: ts.Program) { - if (path.isAbsolute(filePath)) { - return filePath; - } - const compilerOptions = program.getCompilerOptions(); - const basePath = compilerOptions.baseUrl || compilerOptions.rootDir; - if (!basePath) { - throw new Error(`Trying to resolve '${filePath}' without a basePath.`); - } - return path.join(basePath, filePath); -} - - -export class TypeScriptFileRefactor { - private _fileName: string; - private _sourceFile: ts.SourceFile; - private _sourceString: any; - private _sourceText: string; - private _changed: boolean = false; - - get fileName() { return this._fileName; } - get sourceFile() { return this._sourceFile; } - get sourceText() { return this._sourceString.toString(); } - - constructor(fileName: string, - private _host: ts.CompilerHost, - private _program?: ts.Program) { - fileName = resolve(fileName, _host, _program).replace(/\\/g, '/'); - this._fileName = fileName; - if (_program) { - this._sourceFile = _program.getSourceFile(fileName); - } - if (!this._sourceFile) { - this._program = null; - this._sourceFile = ts.createSourceFile(fileName, _host.readFile(fileName), - ts.ScriptTarget.Latest); - } - this._sourceText = this._sourceFile.getFullText(this._sourceFile); - this._sourceString = new MagicString(this._sourceText); - } - - /** - * Collates the diagnostic messages for the current source file - */ - getDiagnostics(): ts.Diagnostic[] { - if (!this._program) { - return []; - } - let diagnostics: ts.Diagnostic[] = this._program.getSyntacticDiagnostics(this._sourceFile) - .concat(this._program.getSemanticDiagnostics(this._sourceFile)); - // only concat the declaration diagnostics if the tsconfig config sets it to true. - if (this._program.getCompilerOptions().declaration == true) { - diagnostics = diagnostics.concat(this._program.getDeclarationDiagnostics(this._sourceFile)); - } - return diagnostics; - } - - /** - * Find all nodes from the AST in the subtree of node of SyntaxKind kind. - * @param node The root node to check, or null if the whole tree should be searched. - * @param kind The kind of nodes to find. - * @param recursive Whether to go in matched nodes to keep matching. - * @param max The maximum number of items to return. - * @return all nodes of kind, or [] if none is found - */ - findAstNodes(node: ts.Node | null, - kind: ts.SyntaxKind, - recursive = false, - max: number = Infinity): ts.Node[] { - if (max == 0) { - return []; - } - if (!node) { - node = this._sourceFile; - } - - let arr: ts.Node[] = []; - if (node.kind === kind) { - // If we're not recursively looking for children, stop here. - if (!recursive) { - return [node]; - } - - arr.push(node); - max--; - } - - if (max > 0) { - for (const child of node.getChildren(this._sourceFile)) { - this.findAstNodes(child, kind, recursive, max) - .forEach((node: ts.Node) => { - if (max > 0) { - arr.push(node); - } - max--; - }); - - if (max <= 0) { - break; - } - } - } - return arr; - } - - appendAfter(node: ts.Node, text: string): void { - this._sourceString.insertRight(node.getEnd(), text); - } - - insertImport(symbolName: string, modulePath: string): void { - // Find all imports. - const allImports = this.findAstNodes(this._sourceFile, ts.SyntaxKind.ImportDeclaration); - const maybeImports = allImports - .filter((node: ts.ImportDeclaration) => { - // Filter all imports that do not match the modulePath. - return node.moduleSpecifier.kind == ts.SyntaxKind.StringLiteral - && (node.moduleSpecifier as ts.StringLiteral).text == modulePath; - }) - .filter((node: ts.ImportDeclaration) => { - // Remove import statements that are either `import 'XYZ'` or `import * as X from 'XYZ'`. - const clause = node.importClause as ts.ImportClause; - if (!clause || clause.name || !clause.namedBindings) { - return false; - } - return clause.namedBindings.kind == ts.SyntaxKind.NamedImports; - }) - .map((node: ts.ImportDeclaration) => { - // Return the `{ ... }` list of the named import. - return (node.importClause as ts.ImportClause).namedBindings as ts.NamedImports; - }); - - if (maybeImports.length) { - // There's an `import {A, B, C} from 'modulePath'`. - // Find if it's in either imports. If so, just return; nothing to do. - const hasImportAlready = maybeImports.some((node: ts.NamedImports) => { - return node.elements.some((element: ts.ImportSpecifier) => { - return element.name.text == symbolName; - }); - }); - if (hasImportAlready) { - return; - } - // Just pick the first one and insert at the end of its identifier list. - this.appendAfter(maybeImports[0].elements[maybeImports[0].elements.length - 1], - `, ${symbolName}`); - } else { - // Find the last import and insert after. - this.appendAfter(allImports[allImports.length - 1], - `import {${symbolName}} from '${modulePath}';`); - } - } - - removeNode(node: ts.Node) { - this._sourceString.remove(node.getStart(this._sourceFile), node.getEnd()); - this._changed = true; - } - - removeNodes(...nodes: ts.Node[]) { - nodes.forEach(node => this.removeNode(node)); - } - - replaceNode(node: ts.Node, replacement: string) { - let replaceSymbolName: boolean = node.kind === ts.SyntaxKind.Identifier; - this._sourceString.overwrite(node.getStart(this._sourceFile), - node.getEnd(), - replacement, - replaceSymbolName); - this._changed = true; - } - - sourceMatch(re: RegExp) { - return this._sourceText.match(re) !== null; - } - - transpile(compilerOptions: ts.CompilerOptions): TranspileOutput { - const source = this.sourceText; - const result = ts.transpileModule(source, { - compilerOptions: Object.assign({}, compilerOptions, { - sourceMap: true, - inlineSources: false, - inlineSourceMap: false, - sourceRoot: '' - }), - fileName: this._fileName - }); - - if (result.sourceMapText) { - const sourceMapJson = JSON.parse(result.sourceMapText); - sourceMapJson.sources = [ this._fileName ]; - - const consumer = new SourceMapConsumer(sourceMapJson); - const map = SourceMapGenerator.fromSourceMap(consumer); - if (this._changed) { - const sourceMap = this._sourceString.generateMap({ - file: path.basename(this._fileName.replace(/\.ts$/, '.js')), - source: this._fileName, - hires: true, - }); - map.applySourceMap(new SourceMapConsumer(sourceMap), this._fileName); - } - - const sourceMap = map.toJSON(); - const fileName = process.platform.startsWith('win') - ? this._fileName.replace(/\//g, '\\') - : this._fileName; - sourceMap.sources = [ fileName ]; - sourceMap.file = path.basename(fileName, '.ts') + '.js'; - sourceMap.sourcesContent = [ this._sourceText ]; - - return { outputText: result.outputText, sourceMap }; - } else { - return { - outputText: result.outputText, - sourceMap: null - }; - } - } -} diff --git a/packages/@ngtools/webpack/src/reflector_host.ts b/packages/@ngtools/webpack/src/reflector_host.ts deleted file mode 100644 index 7b0492ff024c..000000000000 --- a/packages/@ngtools/webpack/src/reflector_host.ts +++ /dev/null @@ -1,26 +0,0 @@ -import {CodeGenerator} from '@angular/compiler-cli'; - - -/** - * Patch the CodeGenerator instance to use a custom reflector host. - */ -export function patchReflectorHost(codeGenerator: CodeGenerator) { - const reflectorHost = (codeGenerator as any).reflectorHost; - const oldGIP = reflectorHost.getImportPath; - - reflectorHost.getImportPath = function(containingFile: string, importedFile: string): string { - // Hack together SCSS and LESS files URLs so that they match what the default ReflectorHost - // is expected. We only do that for shimmed styles. - const m = importedFile.match(/(.*)(\.css|\.scss|\.less|\.stylus)((?:\.shim)?)(\..+)/); - if (!m) { - return oldGIP.call(this, containingFile, importedFile); - } - - // We call the original, with `css` in its name instead of the extension, and replace the - // extension from the result. - const [, baseDirAndName, styleExt, shim, ext] = m; - const result = oldGIP.call(this, containingFile, baseDirAndName + '.css' + shim + ext); - - return result.replace(/\.css($|\.)/, styleExt + '$1'); - }; -} diff --git a/packages/@ngtools/webpack/src/resource_loader.ts b/packages/@ngtools/webpack/src/resource_loader.ts deleted file mode 100644 index 4b146ea1e310..000000000000 --- a/packages/@ngtools/webpack/src/resource_loader.ts +++ /dev/null @@ -1,114 +0,0 @@ -import {ResourceLoader} from '@angular/compiler'; -import {readFileSync} from 'fs'; -import * as vm from 'vm'; -import * as path from 'path'; - -const NodeTemplatePlugin = require('webpack/lib/node/NodeTemplatePlugin'); -const NodeTargetPlugin = require('webpack/lib/node/NodeTargetPlugin'); -const LoaderTargetPlugin = require('webpack/lib/LoaderTargetPlugin'); -const SingleEntryPlugin = require('webpack/lib/SingleEntryPlugin'); - - - -export class WebpackResourceLoader implements ResourceLoader { - private _context: string; - private _uniqueId = 0; - - constructor(private _parentCompilation: any) { - this._context = _parentCompilation.context; - } - - private _compile(filePath: string, content: string): Promise { - const compilerName = `compiler(${this._uniqueId++})`; - const outputOptions = { filename: filePath }; - const relativePath = path.relative(this._context || '', filePath); - const childCompiler = this._parentCompilation.createChildCompiler(relativePath, outputOptions); - childCompiler.context = this._context; - childCompiler.apply( - new NodeTemplatePlugin(outputOptions), - new NodeTargetPlugin(), - new SingleEntryPlugin(this._context, filePath), - new LoaderTargetPlugin('node') - ); - - // Store the result of the parent compilation before we start the child compilation - let assetsBeforeCompilation = Object.assign( - {}, - this._parentCompilation.assets[outputOptions.filename] - ); - - // Fix for "Uncaught TypeError: __webpack_require__(...) is not a function" - // Hot module replacement requires that every child compiler has its own - // cache. @see https://github.com/ampedandwired/html-webpack-plugin/pull/179 - childCompiler.plugin('compilation', function (compilation: any) { - if (compilation.cache) { - if (!compilation.cache[compilerName]) { - compilation.cache[compilerName] = {}; - } - compilation.cache = compilation.cache[compilerName]; - } - }); - - // Compile and return a promise - return new Promise((resolve, reject) => { - childCompiler.runAsChild((err: Error, entries: any[], childCompilation: any) => { - // Resolve / reject the promise - if (childCompilation && childCompilation.errors && childCompilation.errors.length) { - const errorDetails = childCompilation.errors.map(function (error: any) { - return error.message + (error.error ? ':\n' + error.error : ''); - }).join('\n'); - reject(new Error('Child compilation failed:\n' + errorDetails)); - } else if (err) { - reject(err); - } else { - // Replace [hash] placeholders in filename - const outputName = this._parentCompilation.mainTemplate.applyPluginsWaterfall( - 'asset-path', outputOptions.filename, { - hash: childCompilation.hash, - chunk: entries[0] - }); - - // Restore the parent compilation to the state like it was before the child compilation. - this._parentCompilation.assets[outputName] = assetsBeforeCompilation[outputName]; - if (assetsBeforeCompilation[outputName] === undefined) { - // If it wasn't there - delete it. - delete this._parentCompilation.assets[outputName]; - } - - resolve({ - // Hash of the template entry point. - hash: entries[0].hash, - // Output name. - outputName: outputName, - // Compiled code. - content: childCompilation.assets[outputName].source() - }); - } - }); - }); - } - - private _evaluate(fileName: string, source: string): Promise { - try { - const vmContext = vm.createContext(Object.assign({require: require}, global)); - const vmScript = new vm.Script(source, {filename: fileName}); - - // Evaluate code and cast to string - let newSource: string; - newSource = vmScript.runInContext(vmContext); - - if (typeof newSource == 'string') { - return Promise.resolve(newSource); - } - - return Promise.reject('The loader "' + fileName + '" didn\'t return a string.'); - } catch (e) { - return Promise.reject(e); - } - } - - get(filePath: string): Promise { - return this._compile(filePath, readFileSync(filePath, 'utf8')) - .then((result: any) => this._evaluate(result.outputName, result.content)); - } -} diff --git a/packages/@ngtools/webpack/src/utils.ts b/packages/@ngtools/webpack/src/utils.ts deleted file mode 100644 index 954cc206ad9b..000000000000 --- a/packages/@ngtools/webpack/src/utils.ts +++ /dev/null @@ -1,16 +0,0 @@ -const ContextElementDependency = require('webpack/lib/dependencies/ContextElementDependency'); - -export function createResolveDependenciesFromContextMap(createContextMap: Function) { - return (fs: any, resource: any, recursive: any, regExp: RegExp, callback: any) => { - createContextMap(fs, function(err: Error, map: any) { - if (err) { - return callback(err); - } - - const dependencies = Object.keys(map) - .map((key) => new ContextElementDependency(map[key], key)); - - callback(null, dependencies); - }); - }; -} diff --git a/packages/@ngtools/webpack/src/webpack.ts b/packages/@ngtools/webpack/src/webpack.ts deleted file mode 100644 index 0074bed42e12..000000000000 --- a/packages/@ngtools/webpack/src/webpack.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Declarations for (some) Webpack types. Only what's needed. - -export interface Request { - request?: Request; - relativePath: string; -} - -export interface Callback { - (err?: Error | null, result?: T): void; -} - -export interface ResolverCallback { - (request: Request, callback: Callback): void; -} - -export interface Tapable { - apply(plugin: ResolverPlugin): void; -} - -export interface ResolverPlugin extends Tapable { - plugin(source: string, cb: ResolverCallback): void; - doResolve(target: string, req: Request, desc: string, callback: Callback): void; - join(relativePath: string, innerRequest: Request): Request; -} - diff --git a/packages/@ngtools/webpack/tsconfig.json b/packages/@ngtools/webpack/tsconfig.json deleted file mode 100644 index 2712fb19d82d..000000000000 --- a/packages/@ngtools/webpack/tsconfig.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "compilerOptions": { - "declaration": true, - "experimentalDecorators": true, - "mapRoot": "", - "module": "commonjs", - "moduleResolution": "node", - "noEmitOnError": true, - "noImplicitAny": true, - "outDir": "../../../dist/@ngtools/webpack", - "rootDir": ".", - "lib": ["es2015", "es6", "dom"], - "target": "es5", - "sourceMap": true, - "sourceRoot": "/", - "baseUrl": "./", - "paths": { - }, - "typeRoots": [ - "../../node_modules/@types" - ], - "types": [ - "jasmine", - "node" - ] - } -} diff --git a/packages/README.md b/packages/README.md new file mode 100644 index 000000000000..aab7eadc1c92 --- /dev/null +++ b/packages/README.md @@ -0,0 +1,8 @@ +# `/packages` Folder + +This folder is the root of all defined packages in this repository. + +Packages that are marked as `private: true` will not be published to NPM. + +This folder includes a directory for every scope in NPM, without the `@` sign. Then one folder +per package, which contains the `package.json`. diff --git a/packages/angular-cli/addon/index.js b/packages/angular-cli/addon/index.js deleted file mode 100644 index 8b294f9a2718..000000000000 --- a/packages/angular-cli/addon/index.js +++ /dev/null @@ -1,44 +0,0 @@ -/* jshint node: true */ -'use strict'; - -const config = require('../models/config'); -const path = require('path'); - -module.exports = { - name: 'ng2', - - config: function () { - this.project.ngConfigObj = this.project.ngConfigObj || config.CliConfig.fromProject(); - this.project.ngConfig = this.project.ngConfig || this.project.ngConfigObj.config; - }, - - blueprintsPath: function () { - return path.join(__dirname, '../blueprints'); - }, - - includedCommands: function () { - return { - 'build': require('../commands/build').default, - 'serve': require('../commands/serve').default, - 'new': require('../commands/new').default, - 'generate': require('../commands/generate').default, - 'destroy': require('../commands/destroy').default, - 'init': require('../commands/init').default, - 'test': require('../commands/test').default, - 'e2e': require('../commands/e2e').default, - 'help': require('../commands/help').default, - 'lint': require('../commands/lint').default, - 'version': require('../commands/version').default, - 'completion': require('../commands/completion').default, - 'doc': require('../commands/doc').default, - 'github-pages-deploy': require('../commands/github-pages-deploy').default, - - // Easter eggs. - 'make-this-awesome': require('../commands/easter-egg').default, - - // Configuration. - 'set': require('../commands/set').default, - 'get': require('../commands/get').default - }; - } -}; diff --git a/packages/angular-cli/bin/ng b/packages/angular-cli/bin/ng deleted file mode 100755 index 4001df144db1..000000000000 --- a/packages/angular-cli/bin/ng +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -// Provide a title to the process in `ps` -process.title = 'angular-cli'; - -const resolve = require('resolve'); -const packageJson = require('../package.json'); -const Version = require('../upgrade/version').Version; - - -resolve('angular-cli', { basedir: process.cwd() }, - function (error, projectLocalCli) { - var cli; - if (error) { - // If there is an error, resolve could not find the ng-cli - // library from a package.json. Instead, include it from a relative - // path to this script file (which is likely a globally installed - // npm package). Most common cause for hitting this is `ng new` - cli = require('../lib/cli'); - } else { - // Verify that package's version. - Version.assertPostWebpackVersion(); - - // No error implies a projectLocalCli, which will load whatever - // version of ng-cli you have installed in a local package.json - cli = require(projectLocalCli); - } - - if ('default' in cli) { - cli = cli['default']; - } - - cli({ - cliArgs: process.argv.slice(2), - inputStream: process.stdin, - outputStream: process.stdout - }).then(function (result) { - var exitCode = typeof result === 'object' ? result.exitCode : result; - process.exit(exitCode); - }); - }); diff --git a/packages/angular-cli/blueprints/class/files/__path__/__name__.spec.ts b/packages/angular-cli/blueprints/class/files/__path__/__name__.spec.ts deleted file mode 100644 index 86dd09076edd..000000000000 --- a/packages/angular-cli/blueprints/class/files/__path__/__name__.spec.ts +++ /dev/null @@ -1,7 +0,0 @@ -import {<%= classifiedModuleName %>} from './<%= fileName %>'; - -describe('<%= classifiedModuleName %>', () => { - it('should create an instance', () => { - expect(new <%= classifiedModuleName %>()).toBeTruthy(); - }); -}); diff --git a/packages/angular-cli/blueprints/class/files/__path__/__name__.ts b/packages/angular-cli/blueprints/class/files/__path__/__name__.ts deleted file mode 100644 index 54c3f4c0b4cb..000000000000 --- a/packages/angular-cli/blueprints/class/files/__path__/__name__.ts +++ /dev/null @@ -1,2 +0,0 @@ -export class <%= classifiedModuleName %> { -} diff --git a/packages/angular-cli/blueprints/class/index.js b/packages/angular-cli/blueprints/class/index.js deleted file mode 100644 index d76dac94653e..000000000000 --- a/packages/angular-cli/blueprints/class/index.js +++ /dev/null @@ -1,64 +0,0 @@ -const stringUtils = require('ember-cli-string-utils'); -const dynamicPathParser = require('../../utilities/dynamic-path-parser'); -const Blueprint = require('../../ember-cli/lib/models/blueprint'); -const getFiles = Blueprint.prototype.files; - -module.exports = { - description: '', - - availableOptions: [ - { name: 'spec', type: Boolean } - ], - - normalizeEntityName: function (entityName) { - var parsedPath = dynamicPathParser(this.project, entityName.split('.')[0]); - - this.dynamicPath = parsedPath; - return parsedPath.name; - }, - - locals: function (options) { - const rawName = options.args[1]; - const nameParts = rawName.split('.') - .filter(part => part.length !== 0); - - const classType = nameParts[1]; - this.fileName = stringUtils.dasherize(options.entity.name); - if (classType) { - this.fileName += '.' + classType.toLowerCase(); - } - - options.spec = options.spec !== undefined ? - options.spec : - this.project.ngConfigObj.get('defaults.spec.class'); - - return { - dynamicPath: this.dynamicPath.dir, - flat: options.flat, - fileName: this.fileName - }; - }, - - files: function() { - var fileList = getFiles.call(this); - - if (this.options && !this.options.spec) { - fileList = fileList.filter(p => p.indexOf('__name__.spec.ts') < 0); - } - - return fileList; - }, - - fileMapTokens: function () { - // Return custom template variables here. - return { - __path__: () => { - this.generatePath = this.dynamicPath.dir; - return this.generatePath; - }, - __name__: () => { - return this.fileName; - } - }; - } -}; diff --git a/packages/angular-cli/blueprints/component/files/__path__/__name__.component.html b/packages/angular-cli/blueprints/component/files/__path__/__name__.component.html deleted file mode 100644 index 200001f88129..000000000000 --- a/packages/angular-cli/blueprints/component/files/__path__/__name__.component.html +++ /dev/null @@ -1,3 +0,0 @@ -

- <%= dasherizedModuleName %> works! -

diff --git a/packages/angular-cli/blueprints/component/files/__path__/__name__.component.spec.ts b/packages/angular-cli/blueprints/component/files/__path__/__name__.component.spec.ts deleted file mode 100644 index dd1fec0b6e9e..000000000000 --- a/packages/angular-cli/blueprints/component/files/__path__/__name__.component.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* tslint:disable:no-unused-variable */ -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { DebugElement } from '@angular/core'; - -import { <%= classifiedModuleName %>Component } from './<%= dasherizedModuleName %>.component'; - -describe('<%= classifiedModuleName %>Component', () => { - let component: <%= classifiedModuleName %>Component; - let fixture: ComponentFixture<<%= classifiedModuleName %>Component>; - - beforeEach(async(() => { - TestBed.configureTestingModule({ - declarations: [ <%= classifiedModuleName %>Component ] - }) - .compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(<%= classifiedModuleName %>Component); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/packages/angular-cli/blueprints/component/files/__path__/__name__.component.ts b/packages/angular-cli/blueprints/component/files/__path__/__name__.component.ts deleted file mode 100644 index 2b23a2899848..000000000000 --- a/packages/angular-cli/blueprints/component/files/__path__/__name__.component.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Component, OnInit<% if(viewEncapsulation) { %>, ViewEncapsulation<% }%><% if(changeDetection) { %>, ChangeDetectionStrategy<% }%> } from '@angular/core'; - -@Component({ - selector: '<%= selector %>',<% if(inlineTemplate) { %> - template: ` -

- <%= dasherizedModuleName %> Works! -

- `,<% } else { %> - templateUrl: './<%= dasherizedModuleName %>.component.html',<% } if(inlineStyle) { %> - styles: []<% } else { %> - styleUrls: ['./<%= dasherizedModuleName %>.component.<%= styleExt %>']<% } %><% if(viewEncapsulation) { %>, - encapsulation: ViewEncapsulation.<%= viewEncapsulation %><% } if (changeDetection) { %>, - changeDetection: ChangeDetectionStrategy.<%= changeDetection %><% } %> -}) -export class <%= classifiedModuleName %>Component implements OnInit { - - constructor() { } - - ngOnInit() { - } - -} diff --git a/packages/angular-cli/blueprints/component/index.js b/packages/angular-cli/blueprints/component/index.js deleted file mode 100644 index b1c9c94999bd..000000000000 --- a/packages/angular-cli/blueprints/component/index.js +++ /dev/null @@ -1,157 +0,0 @@ -const path = require('path'); -const chalk = require('chalk'); -const Blueprint = require('../../ember-cli/lib/models/blueprint'); -const dynamicPathParser = require('../../utilities/dynamic-path-parser'); -const findParentModule = require('../../utilities/find-parent-module').default; -const getFiles = Blueprint.prototype.files; -const stringUtils = require('ember-cli-string-utils'); -const astUtils = require('../../utilities/ast-utils'); -const NodeHost = require('@angular-cli/ast-tools').NodeHost; - -module.exports = { - description: '', - - availableOptions: [ - { name: 'flat', type: Boolean, default: false }, - { name: 'inline-template', type: Boolean, aliases: ['it'] }, - { name: 'inline-style', type: Boolean, aliases: ['is'] }, - { name: 'prefix', type: String, default: null }, - { name: 'spec', type: Boolean }, - { name: 'view-encapsulation', type: String, aliases: ['ve'] }, - { name: 'change-detection', type: String, aliases: ['cd'] }, - { name: 'skip-import', type: Boolean, default: false } - ], - - beforeInstall: function(options) { - try { - this.pathToModule = findParentModule(this.project, this.dynamicPath.dir); - } catch(e) { - if (!options.skipImport) { - throw `Error locating module for declaration\n\t${e}`; - } - } - }, - - normalizeEntityName: function (entityName) { - var parsedPath = dynamicPathParser(this.project, entityName); - - this.dynamicPath = parsedPath; - - var defaultPrefix = ''; - if (this.project.ngConfig && - this.project.ngConfig.apps[0] && - this.project.ngConfig.apps[0].prefix) { - defaultPrefix = this.project.ngConfig.apps[0].prefix; - } - - var prefix = (this.options.prefix === 'false' || this.options.prefix === '') ? '' : (this.options.prefix || defaultPrefix); - prefix = prefix && `${prefix}-`; - - this.selector = stringUtils.dasherize(prefix + parsedPath.name); - - if (this.selector.indexOf('-') === -1) { - this._writeStatusToUI(chalk.yellow, 'WARNING', 'selectors should contain a dash'); - } - - return parsedPath.name; - }, - - locals: function (options) { - this.styleExt = 'css'; - if (this.project.ngConfig && - this.project.ngConfig.defaults && - this.project.ngConfig.defaults.styleExt) { - this.styleExt = this.project.ngConfig.defaults.styleExt; - } - - options.inlineStyle = options.inlineStyle !== undefined ? - options.inlineStyle : - this.project.ngConfigObj.get('defaults.inline.style'); - - options.inlineTemplate = options.inlineTemplate !== undefined ? - options.inlineTemplate : - this.project.ngConfigObj.get('defaults.inline.template'); - - options.spec = options.spec !== undefined ? - options.spec : - this.project.ngConfigObj.get('defaults.spec.component'); - - options.viewEncapsulation = options.viewEncapsulation !== undefined ? - options.viewEncapsulation : - this.project.ngConfigObj.get('defaults.viewEncapsulation'); - - options.changeDetection = options.changeDetection !== undefined ? - options.changeDetection : - this.project.ngConfigObj.get('defaults.changeDetection'); - - return { - dynamicPath: this.dynamicPath.dir.replace(this.dynamicPath.appRoot, ''), - flat: options.flat, - spec: options.spec, - inlineTemplate: options.inlineTemplate, - inlineStyle: options.inlineStyle, - route: options.route, - isAppComponent: !!options.isAppComponent, - selector: this.selector, - styleExt: this.styleExt, - viewEncapsulation: options.viewEncapsulation, - changeDetection: options.changeDetection - }; - }, - - files: function() { - var fileList = getFiles.call(this); - - if (this.options && this.options.inlineTemplate) { - fileList = fileList.filter(p => p.indexOf('.html') < 0); - } - if (this.options && this.options.inlineStyle) { - fileList = fileList.filter(p => p.indexOf('.__styleext__') < 0); - } - if (this.options && !this.options.spec) { - fileList = fileList.filter(p => p.indexOf('__name__.component.spec.ts') < 0); - } - - return fileList; - }, - - fileMapTokens: function (options) { - // Return custom template variables here. - return { - __path__: () => { - var dir = this.dynamicPath.dir; - if (!options.locals.flat) { - dir += path.sep + options.dasherizedModuleName; - } - var srcDir = this.project.ngConfig.apps[0].root; - this.appDir = dir.substr(dir.indexOf(srcDir) + srcDir.length); - this.generatePath = dir; - return dir; - }, - __styleext__: () => { - return this.styleExt; - } - }; - }, - - afterInstall: function(options) { - if (options.dryRun) { - return; - } - - const returns = []; - const className = stringUtils.classify(`${options.entity.name}Component`); - const fileName = stringUtils.dasherize(`${options.entity.name}.component`); - const componentDir = path.relative(path.dirname(this.pathToModule), this.generatePath); - const importPath = componentDir ? `./${componentDir}/${fileName}` : `./${fileName}`; - - if (!options.skipImport) { - returns.push( - astUtils.addDeclarationToModule(this.pathToModule, className, importPath) - .then(change => change.apply(NodeHost))); - this._writeStatusToUI(chalk.yellow, 'update', path.relative(this.project.root, this.pathToModule)); - } - - return Promise.all(returns); - } -}; diff --git a/packages/angular-cli/blueprints/directive/files/__path__/__name__.directive.spec.ts b/packages/angular-cli/blueprints/directive/files/__path__/__name__.directive.spec.ts deleted file mode 100644 index b4c4aee63d0a..000000000000 --- a/packages/angular-cli/blueprints/directive/files/__path__/__name__.directive.spec.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* tslint:disable:no-unused-variable */ - -import { TestBed, async } from '@angular/core/testing'; -import { <%= classifiedModuleName %>Directive } from './<%= dasherizedModuleName %>.directive'; - -describe('<%= classifiedModuleName %>Directive', () => { - it('should create an instance', () => { - let directive = new <%= classifiedModuleName %>Directive(); - expect(directive).toBeTruthy(); - }); -}); diff --git a/packages/angular-cli/blueprints/directive/files/__path__/__name__.directive.ts b/packages/angular-cli/blueprints/directive/files/__path__/__name__.directive.ts deleted file mode 100644 index ee8bd3254f68..000000000000 --- a/packages/angular-cli/blueprints/directive/files/__path__/__name__.directive.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Directive } from '@angular/core'; - -@Directive({ - selector: '[<%= selector %>]' -}) -export class <%= classifiedModuleName %>Directive { - - constructor() { } - -} diff --git a/packages/angular-cli/blueprints/directive/index.js b/packages/angular-cli/blueprints/directive/index.js deleted file mode 100644 index 00765faf56ae..000000000000 --- a/packages/angular-cli/blueprints/directive/index.js +++ /dev/null @@ -1,109 +0,0 @@ -const path = require('path'); -const chalk = require('chalk'); -const dynamicPathParser = require('../../utilities/dynamic-path-parser'); -const stringUtils = require('ember-cli-string-utils'); -const astUtils = require('../../utilities/ast-utils'); -const findParentModule = require('../../utilities/find-parent-module').default; -const NodeHost = require('@angular-cli/ast-tools').NodeHost; -const Blueprint = require('../../ember-cli/lib/models/blueprint'); -const getFiles = Blueprint.prototype.files; - -module.exports = { - description: '', - - availableOptions: [ - { name: 'flat', type: Boolean, default: true }, - { name: 'prefix', type: String, default: null }, - { name: 'spec', type: Boolean }, - { name: 'skip-import', type: Boolean, default: false } - ], - - beforeInstall: function(options) { - try { - this.pathToModule = findParentModule(this.project, this.dynamicPath.dir); - } catch(e) { - if (!options.skipImport) { - throw `Error locating module for declaration\n\t${e}`; - } - } - }, - - normalizeEntityName: function (entityName) { - var parsedPath = dynamicPathParser(this.project, entityName); - - this.dynamicPath = parsedPath; - - var defaultPrefix = ''; - if (this.project.ngConfig && - this.project.ngConfig.apps[0] && - this.project.ngConfig.apps[0].prefix) { - defaultPrefix = this.project.ngConfig.apps[0].prefix; - } - - var prefix = (this.options.prefix === 'false' || this.options.prefix === '') ? '' : (this.options.prefix || defaultPrefix); - prefix = prefix && `${prefix}-`; - - - this.selector = stringUtils.camelize(prefix + parsedPath.name); - return parsedPath.name; - }, - - locals: function (options) { - options.spec = options.spec !== undefined ? - options.spec : - this.project.ngConfigObj.get('defaults.spec.directive'); - - return { - dynamicPath: this.dynamicPath.dir, - flat: options.flat, - selector: this.selector - }; - }, - - files: function() { - var fileList = getFiles.call(this); - - if (this.options && !this.options.spec) { - fileList = fileList.filter(p => p.indexOf('__name__.directive.spec.ts') < 0); - } - - return fileList; - }, - - fileMapTokens: function (options) { - // Return custom template variables here. - return { - __path__: () => { - var dir = this.dynamicPath.dir; - if (!options.locals.flat) { - dir += path.sep + options.dasherizedModuleName; - } - this.generatePath = dir; - return dir; - } - }; - }, - - afterInstall: function(options) { - if (options.dryRun) { - return; - } - - const returns = []; - const className = stringUtils.classify(`${options.entity.name}Directive`); - const fileName = stringUtils.dasherize(`${options.entity.name}.directive`); - const fullGeneratePath = path.join(this.project.root, this.generatePath); - const moduleDir = path.parse(this.pathToModule).dir; - const relativeDir = path.relative(moduleDir, fullGeneratePath); - const importPath = relativeDir ? `./${relativeDir}/${fileName}` : `./${fileName}`; - - if (!options.skipImport) { - returns.push( - astUtils.addDeclarationToModule(this.pathToModule, className, importPath) - .then(change => change.apply(NodeHost))); - this._writeStatusToUI(chalk.yellow, 'update', path.relative(this.project.root, this.pathToModule)); - } - - return Promise.all(returns); - } -}; diff --git a/packages/angular-cli/blueprints/enum/files/__path__/__name__.enum.ts b/packages/angular-cli/blueprints/enum/files/__path__/__name__.enum.ts deleted file mode 100644 index 18ef0183ef09..000000000000 --- a/packages/angular-cli/blueprints/enum/files/__path__/__name__.enum.ts +++ /dev/null @@ -1,2 +0,0 @@ -export enum <%= classifiedModuleName %> { -} diff --git a/packages/angular-cli/blueprints/enum/index.js b/packages/angular-cli/blueprints/enum/index.js deleted file mode 100644 index d4e5b8bce2d8..000000000000 --- a/packages/angular-cli/blueprints/enum/index.js +++ /dev/null @@ -1,36 +0,0 @@ -const stringUtils = require('ember-cli-string-utils'); -var dynamicPathParser = require('../../utilities/dynamic-path-parser'); - -module.exports = { - description: '', - - normalizeEntityName: function (entityName) { - var parsedPath = dynamicPathParser(this.project, entityName); - - this.dynamicPath = parsedPath; - return parsedPath.name; - }, - - locals: function (options) { - this.fileName = stringUtils.dasherize(options.entity.name); - - return { - dynamicPath: this.dynamicPath.dir, - flat: options.flat, - fileName: this.fileName - }; - }, - - fileMapTokens: function () { - // Return custom template variables here. - return { - __path__: () => { - this.generatePath = this.dynamicPath.dir; - return this.generatePath; - }, - __name__: () => { - return this.fileName; - } - }; - } -}; diff --git a/packages/angular-cli/blueprints/interface/files/__path__/__name__.ts b/packages/angular-cli/blueprints/interface/files/__path__/__name__.ts deleted file mode 100644 index a329d9e8dd0b..000000000000 --- a/packages/angular-cli/blueprints/interface/files/__path__/__name__.ts +++ /dev/null @@ -1,2 +0,0 @@ -export interface <%= prefix %><%= classifiedModuleName %> { -} diff --git a/packages/angular-cli/blueprints/interface/index.js b/packages/angular-cli/blueprints/interface/index.js deleted file mode 100644 index f14afb9525d0..000000000000 --- a/packages/angular-cli/blueprints/interface/index.js +++ /dev/null @@ -1,50 +0,0 @@ -const stringUtils = require('ember-cli-string-utils'); -var dynamicPathParser = require('../../utilities/dynamic-path-parser'); - -module.exports = { - description: '', - - anonymousOptions: [ - '' - ], - - normalizeEntityName: function (entityName) { - var parsedPath = dynamicPathParser(this.project, entityName); - - this.dynamicPath = parsedPath; - return parsedPath.name; - }, - - locals: function (options) { - var interfaceType = options.args [2] - this.fileName = stringUtils.dasherize(options.entity.name); - if (interfaceType) { - this.fileName += '.' + interfaceType; - } - var prefix = ''; - if (this.project.ngConfig && - this.project.ngConfig.defaults && - this.project.ngConfig.defaults.prefixInterfaces) { - prefix = 'I'; - } - return { - dynamicPath: this.dynamicPath.dir, - flat: options.flat, - fileName: this.fileName, - prefix: prefix - }; - }, - - fileMapTokens: function () { - // Return custom template variables here. - return { - __path__: () => { - this.generatePath = this.dynamicPath.dir; - return this.generatePath; - }, - __name__: () => { - return this.fileName; - } - }; - } -}; diff --git a/packages/angular-cli/blueprints/mobile/files/__path__/icons/android-chrome-144x144.png b/packages/angular-cli/blueprints/mobile/files/__path__/icons/android-chrome-144x144.png deleted file mode 100644 index a64dd11b7475..000000000000 Binary files a/packages/angular-cli/blueprints/mobile/files/__path__/icons/android-chrome-144x144.png and /dev/null differ diff --git a/packages/angular-cli/blueprints/mobile/files/__path__/icons/android-chrome-192x192.png b/packages/angular-cli/blueprints/mobile/files/__path__/icons/android-chrome-192x192.png deleted file mode 100644 index 4ea56800c064..000000000000 Binary files a/packages/angular-cli/blueprints/mobile/files/__path__/icons/android-chrome-192x192.png and /dev/null differ diff --git a/packages/angular-cli/blueprints/mobile/files/__path__/icons/android-chrome-36x36.png b/packages/angular-cli/blueprints/mobile/files/__path__/icons/android-chrome-36x36.png deleted file mode 100644 index 0f8ba0b53ab1..000000000000 Binary files a/packages/angular-cli/blueprints/mobile/files/__path__/icons/android-chrome-36x36.png and /dev/null differ diff --git a/packages/angular-cli/blueprints/mobile/files/__path__/icons/android-chrome-48x48.png b/packages/angular-cli/blueprints/mobile/files/__path__/icons/android-chrome-48x48.png deleted file mode 100644 index 825857550f6f..000000000000 Binary files a/packages/angular-cli/blueprints/mobile/files/__path__/icons/android-chrome-48x48.png and /dev/null differ diff --git a/packages/angular-cli/blueprints/mobile/files/__path__/icons/android-chrome-72x72.png b/packages/angular-cli/blueprints/mobile/files/__path__/icons/android-chrome-72x72.png deleted file mode 100644 index f5f4daa4e51f..000000000000 Binary files a/packages/angular-cli/blueprints/mobile/files/__path__/icons/android-chrome-72x72.png and /dev/null differ diff --git a/packages/angular-cli/blueprints/mobile/files/__path__/icons/android-chrome-96x96.png b/packages/angular-cli/blueprints/mobile/files/__path__/icons/android-chrome-96x96.png deleted file mode 100644 index 36b43addc2f4..000000000000 Binary files a/packages/angular-cli/blueprints/mobile/files/__path__/icons/android-chrome-96x96.png and /dev/null differ diff --git a/packages/angular-cli/blueprints/mobile/files/__path__/icons/apple-touch-icon-114x114.png b/packages/angular-cli/blueprints/mobile/files/__path__/icons/apple-touch-icon-114x114.png deleted file mode 100644 index c30f9dbc7a4f..000000000000 Binary files a/packages/angular-cli/blueprints/mobile/files/__path__/icons/apple-touch-icon-114x114.png and /dev/null differ diff --git a/packages/angular-cli/blueprints/mobile/files/__path__/icons/apple-touch-icon-120x120.png b/packages/angular-cli/blueprints/mobile/files/__path__/icons/apple-touch-icon-120x120.png deleted file mode 100644 index ee44ccff7fa9..000000000000 Binary files a/packages/angular-cli/blueprints/mobile/files/__path__/icons/apple-touch-icon-120x120.png and /dev/null differ diff --git a/packages/angular-cli/blueprints/mobile/files/__path__/icons/apple-touch-icon-144x144.png b/packages/angular-cli/blueprints/mobile/files/__path__/icons/apple-touch-icon-144x144.png deleted file mode 100644 index 74ec622748bb..000000000000 Binary files a/packages/angular-cli/blueprints/mobile/files/__path__/icons/apple-touch-icon-144x144.png and /dev/null differ diff --git a/packages/angular-cli/blueprints/mobile/files/__path__/icons/apple-touch-icon-152x152.png b/packages/angular-cli/blueprints/mobile/files/__path__/icons/apple-touch-icon-152x152.png deleted file mode 100644 index 38105cff2205..000000000000 Binary files a/packages/angular-cli/blueprints/mobile/files/__path__/icons/apple-touch-icon-152x152.png and /dev/null differ diff --git a/packages/angular-cli/blueprints/mobile/files/__path__/icons/apple-touch-icon-180x180.png b/packages/angular-cli/blueprints/mobile/files/__path__/icons/apple-touch-icon-180x180.png deleted file mode 100644 index 3e4de71ecb3c..000000000000 Binary files a/packages/angular-cli/blueprints/mobile/files/__path__/icons/apple-touch-icon-180x180.png and /dev/null differ diff --git a/packages/angular-cli/blueprints/mobile/files/__path__/icons/apple-touch-icon-57x57.png b/packages/angular-cli/blueprints/mobile/files/__path__/icons/apple-touch-icon-57x57.png deleted file mode 100644 index ef2963a08e83..000000000000 Binary files a/packages/angular-cli/blueprints/mobile/files/__path__/icons/apple-touch-icon-57x57.png and /dev/null differ diff --git a/packages/angular-cli/blueprints/mobile/files/__path__/icons/apple-touch-icon-60x60.png b/packages/angular-cli/blueprints/mobile/files/__path__/icons/apple-touch-icon-60x60.png deleted file mode 100644 index 0db9f37c5af7..000000000000 Binary files a/packages/angular-cli/blueprints/mobile/files/__path__/icons/apple-touch-icon-60x60.png and /dev/null differ diff --git a/packages/angular-cli/blueprints/mobile/files/__path__/icons/apple-touch-icon-72x72.png b/packages/angular-cli/blueprints/mobile/files/__path__/icons/apple-touch-icon-72x72.png deleted file mode 100644 index 565bc46ba7b7..000000000000 Binary files a/packages/angular-cli/blueprints/mobile/files/__path__/icons/apple-touch-icon-72x72.png and /dev/null differ diff --git a/packages/angular-cli/blueprints/mobile/files/__path__/icons/apple-touch-icon-76x76.png b/packages/angular-cli/blueprints/mobile/files/__path__/icons/apple-touch-icon-76x76.png deleted file mode 100644 index 05f04aba19df..000000000000 Binary files a/packages/angular-cli/blueprints/mobile/files/__path__/icons/apple-touch-icon-76x76.png and /dev/null differ diff --git a/packages/angular-cli/blueprints/mobile/files/__path__/icons/apple-touch-icon-precomposed.png b/packages/angular-cli/blueprints/mobile/files/__path__/icons/apple-touch-icon-precomposed.png deleted file mode 100644 index a4babe853d99..000000000000 Binary files a/packages/angular-cli/blueprints/mobile/files/__path__/icons/apple-touch-icon-precomposed.png and /dev/null differ diff --git a/packages/angular-cli/blueprints/mobile/files/__path__/icons/apple-touch-icon.png b/packages/angular-cli/blueprints/mobile/files/__path__/icons/apple-touch-icon.png deleted file mode 100644 index 3e4de71ecb3c..000000000000 Binary files a/packages/angular-cli/blueprints/mobile/files/__path__/icons/apple-touch-icon.png and /dev/null differ diff --git a/packages/angular-cli/blueprints/mobile/files/__path__/icons/favicon-16x16.png b/packages/angular-cli/blueprints/mobile/files/__path__/icons/favicon-16x16.png deleted file mode 100644 index df3ce0d7e7e2..000000000000 Binary files a/packages/angular-cli/blueprints/mobile/files/__path__/icons/favicon-16x16.png and /dev/null differ diff --git a/packages/angular-cli/blueprints/mobile/files/__path__/icons/favicon-32x32.png b/packages/angular-cli/blueprints/mobile/files/__path__/icons/favicon-32x32.png deleted file mode 100644 index 93cf0641e5bc..000000000000 Binary files a/packages/angular-cli/blueprints/mobile/files/__path__/icons/favicon-32x32.png and /dev/null differ diff --git a/packages/angular-cli/blueprints/mobile/files/__path__/icons/favicon-96x96.png b/packages/angular-cli/blueprints/mobile/files/__path__/icons/favicon-96x96.png deleted file mode 100644 index 36b43addc2f4..000000000000 Binary files a/packages/angular-cli/blueprints/mobile/files/__path__/icons/favicon-96x96.png and /dev/null differ diff --git a/packages/angular-cli/blueprints/mobile/files/__path__/icons/icon.png b/packages/angular-cli/blueprints/mobile/files/__path__/icons/icon.png deleted file mode 100644 index 4dace951fab2..000000000000 Binary files a/packages/angular-cli/blueprints/mobile/files/__path__/icons/icon.png and /dev/null differ diff --git a/packages/angular-cli/blueprints/mobile/files/__path__/icons/mstile-144x144.png b/packages/angular-cli/blueprints/mobile/files/__path__/icons/mstile-144x144.png deleted file mode 100644 index 9ca91770760b..000000000000 Binary files a/packages/angular-cli/blueprints/mobile/files/__path__/icons/mstile-144x144.png and /dev/null differ diff --git a/packages/angular-cli/blueprints/mobile/files/__path__/icons/mstile-150x150.png b/packages/angular-cli/blueprints/mobile/files/__path__/icons/mstile-150x150.png deleted file mode 100644 index 444b3bf92572..000000000000 Binary files a/packages/angular-cli/blueprints/mobile/files/__path__/icons/mstile-150x150.png and /dev/null differ diff --git a/packages/angular-cli/blueprints/mobile/files/__path__/icons/mstile-310x150.png b/packages/angular-cli/blueprints/mobile/files/__path__/icons/mstile-310x150.png deleted file mode 100644 index 59e907107579..000000000000 Binary files a/packages/angular-cli/blueprints/mobile/files/__path__/icons/mstile-310x150.png and /dev/null differ diff --git a/packages/angular-cli/blueprints/mobile/files/__path__/icons/mstile-310x310.png b/packages/angular-cli/blueprints/mobile/files/__path__/icons/mstile-310x310.png deleted file mode 100644 index 143e9d67cb25..000000000000 Binary files a/packages/angular-cli/blueprints/mobile/files/__path__/icons/mstile-310x310.png and /dev/null differ diff --git a/packages/angular-cli/blueprints/mobile/files/__path__/icons/mstile-70x70.png b/packages/angular-cli/blueprints/mobile/files/__path__/icons/mstile-70x70.png deleted file mode 100644 index 285924f08b7c..000000000000 Binary files a/packages/angular-cli/blueprints/mobile/files/__path__/icons/mstile-70x70.png and /dev/null differ diff --git a/packages/angular-cli/blueprints/mobile/files/__path__/icons/safari-pinned-tab.svg b/packages/angular-cli/blueprints/mobile/files/__path__/icons/safari-pinned-tab.svg deleted file mode 100644 index 0d6f7ea39538..000000000000 --- a/packages/angular-cli/blueprints/mobile/files/__path__/icons/safari-pinned-tab.svg +++ /dev/null @@ -1,23 +0,0 @@ - - - - -Created by potrace 1.11, written by Peter Selinger 2001-2013 - - - - - diff --git a/packages/angular-cli/blueprints/mobile/files/__path__/main-app-shell.ts b/packages/angular-cli/blueprints/mobile/files/__path__/main-app-shell.ts deleted file mode 100644 index d3e009cb19c5..000000000000 --- a/packages/angular-cli/blueprints/mobile/files/__path__/main-app-shell.ts +++ /dev/null @@ -1,47 +0,0 @@ -import 'angular2-universal-polyfills'; -import { provide } from '@angular/core'; -import { APP_BASE_HREF } from '@angular/common'; -import { APP_SHELL_BUILD_PROVIDERS } from '@angular/app-shell'; -import { - REQUEST_URL, - ORIGIN_URL, - Bootloader, - BootloaderConfig, - AppConfig -} from 'angular2-universal'; -import { AppComponent } from './app/'; - -const bootloaderConfig: BootloaderConfig = { - platformProviders: [ - APP_SHELL_BUILD_PROVIDERS, - provide(ORIGIN_URL, { - useValue: 'http://localhost:4200' // full urls are needed for node xhr - }), - provide(APP_BASE_HREF, { useValue: '/' }), - ], - async: true, - preboot: false -} - -const appConfig: AppConfig = { - directives: [ - // The component that will become the main App Shell - AppComponent - ], - providers: [ - // What URL should Angular be treating the app as if navigating - provide(REQUEST_URL, { useValue: '/' }) - ] -} - -// The build system will call this function to get a bootloader -export function getBootloader() : Bootloader { - return new Bootloader(bootloaderConfig); -} - -// The build system will call this function with the bootloader from -// getBootloader and the contents of the index page -export function serialize(bootloader: Bootloader, template: string) : string { - appConfig.template = template; - return bootloader.serializeApplication(appConfig); -} \ No newline at end of file diff --git a/packages/angular-cli/blueprints/mobile/files/__path__/manifest.webapp b/packages/angular-cli/blueprints/mobile/files/__path__/manifest.webapp deleted file mode 100644 index 0c8bdd70ec23..000000000000 --- a/packages/angular-cli/blueprints/mobile/files/__path__/manifest.webapp +++ /dev/null @@ -1,41 +0,0 @@ -{ - "name": "<%= fullAppName %>", - "short_name": "<%= fullAppName %>", - "icons": [ - { - "src": "icons/android-chrome-36x36.png", - "sizes": "36x36", - "type": "image/png" - }, - { - "src": "icons/android-chrome-48x48.png", - "sizes": "48x48", - "type": "image/png" - }, - { - "src": "icons/android-chrome-72x72.png", - "sizes": "72x72", - "type": "image/png" - }, - { - "src": "icons/android-chrome-96x96.png", - "sizes": "96x96", - "type": "image/png" - }, - { - "src": "icons/android-chrome-144x144.png", - "sizes": "144x144", - "type": "image/png" - }, - { - "src": "icons/android-chrome-192x192.png", - "sizes": "192x192", - "type": "image/png" - } - ], - "theme_color": "#000000", - "background_color": "#e0e0e0", - "start_url": "/index.html", - "display": "standalone", - "orientation": "portrait" -} diff --git a/packages/angular-cli/blueprints/mobile/index.js b/packages/angular-cli/blueprints/mobile/index.js deleted file mode 100644 index 4776d04ff24d..000000000000 --- a/packages/angular-cli/blueprints/mobile/index.js +++ /dev/null @@ -1,32 +0,0 @@ -const stringUtils = require('ember-cli-string-utils'); - -module.exports = { - description: '', - - availableOptions: [ - { name: 'source-dir', type: String, default: 'src', aliases: ['sd'] }, - { name: 'prefix', type: String, default: 'app', aliases: ['p'] }, - { name: 'mobile', type: Boolean, default: false } - ], - - locals: function (options) { - const fullAppName = stringUtils.dasherize(options.entity.name) - .replace(/-(.)/g, (_, l) => ' ' + l.toUpperCase()) - .replace(/^./, (l) => l.toUpperCase()); - - return { - jsComponentName: stringUtils.classify(options.entity.name), - fullAppName: fullAppName, - sourceDir: options.sourceDir - }; - }, - - fileMapTokens: function (options) { - // Return custom template variables here. - return { - __path__: () => { - return options.locals.sourceDir; - } - }; - } -}; diff --git a/packages/angular-cli/blueprints/module/files/__path__/__name__-routing.module.ts b/packages/angular-cli/blueprints/module/files/__path__/__name__-routing.module.ts deleted file mode 100644 index 2d4459211035..000000000000 --- a/packages/angular-cli/blueprints/module/files/__path__/__name__-routing.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { NgModule } from '@angular/core'; -import { Routes, RouterModule } from '@angular/router'; - -const routes: Routes = []; - -@NgModule({ - imports: [RouterModule.forChild(routes)], - exports: [RouterModule], - providers: [] -}) -export class <%= classifiedModuleName %>RoutingModule { } diff --git a/packages/angular-cli/blueprints/module/files/__path__/__name__.module.spec.ts b/packages/angular-cli/blueprints/module/files/__path__/__name__.module.spec.ts deleted file mode 100644 index 5a5bb7d753dd..000000000000 --- a/packages/angular-cli/blueprints/module/files/__path__/__name__.module.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* tslint:disable:no-unused-variable */ - -import { TestBed, async } from '@angular/core/testing'; -import <%= classifiedModuleName %>Module from './<%= dasherizedModuleName %>.module'; - -describe('<%= classifiedModuleName %>Module', () => { - let <%= camelizedModuleName %>Module; - - beforeEach(() => { - <%= camelizedModuleName %>Module = new <%= classifiedModuleName %>Module(); - }); - - it('should create an instance', () => { - expect(<%= camelizedModuleName %>Module).toBeTruthy(); - }) -}); diff --git a/packages/angular-cli/blueprints/module/files/__path__/__name__.module.ts b/packages/angular-cli/blueprints/module/files/__path__/__name__.module.ts deleted file mode 100644 index 2505c216531f..000000000000 --- a/packages/angular-cli/blueprints/module/files/__path__/__name__.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common';<% if (routing) { %> -import { <%= classifiedModuleName %>RoutingModule } from './<%= dasherizedModuleName %>-routing.module';<% } %> - -@NgModule({ - imports: [ - CommonModule<% if (routing) { %>, - <%= classifiedModuleName %>RoutingModule<% } %> - ], - declarations: [] -}) -export class <%= classifiedModuleName %>Module { } diff --git a/packages/angular-cli/blueprints/module/index.js b/packages/angular-cli/blueprints/module/index.js deleted file mode 100644 index 035a1355d7f2..000000000000 --- a/packages/angular-cli/blueprints/module/index.js +++ /dev/null @@ -1,76 +0,0 @@ -const path = require('path'); -const Blueprint = require('../../ember-cli/lib/models/blueprint'); -const dynamicPathParser = require('../../utilities/dynamic-path-parser'); -const getFiles = Blueprint.prototype.files; - -module.exports = { - description: '', - - availableOptions: [ - { name: 'spec', type: Boolean }, - { name: 'routing', type: Boolean, default: false } - ], - - normalizeEntityName: function (entityName) { - this.entityName = entityName; - var parsedPath = dynamicPathParser(this.project, entityName); - - this.dynamicPath = parsedPath; - return parsedPath.name; - }, - - locals: function (options) { - options.spec = options.spec !== undefined ? - options.spec : - this.project.ngConfigObj.get('defaults.spec.module'); - - return { - dynamicPath: this.dynamicPath.dir, - spec: options.spec, - routing: options.routing - }; - }, - - files: function() { - var fileList = getFiles.call(this); - - if (!this.options || !this.options.spec) { - fileList = fileList.filter(p => p.indexOf('__name__.module.spec.ts') < 0); - } - if (this.options && !this.options.routing) { - fileList = fileList.filter(p => p.indexOf('__name__-routing.module.ts') < 0); - } - - return fileList; - }, - - fileMapTokens: function (options) { - // Return custom template variables here. - this.dasherizedModuleName = options.dasherizedModuleName; - return { - __path__: () => { - this.generatePath = this.dynamicPath.dir - + path.sep - + options.dasherizedModuleName; - return this.generatePath; - } - }; - }, - - afterInstall: function (options) { - // Note that `this.generatePath` already contains `this.dasherizedModuleName` - // So, the path will end like `name/name`, - // which is correct for `name.component.ts` created in module `name` - if (this.options && this.options.routing) { - var componentPath = path.join(this.generatePath, this.dasherizedModuleName); - options.entity.name = path.relative(this.dynamicPath.appRoot, componentPath); - options.flat = true; - options.route = false; - options.inlineTemplate = false; - options.inlineStyle = false; - options.prefix = null; - options.spec = true; - return Blueprint.load(path.join(__dirname, '../component')).install(options); - } - } -}; diff --git a/packages/angular-cli/blueprints/ng2/files/.editorconfig b/packages/angular-cli/blueprints/ng2/files/.editorconfig deleted file mode 100644 index 6e87a003da89..000000000000 --- a/packages/angular-cli/blueprints/ng2/files/.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/packages/angular-cli/blueprints/ng2/files/README.md b/packages/angular-cli/blueprints/ng2/files/README.md deleted file mode 100755 index 851fb1af4de2..000000000000 --- a/packages/angular-cli/blueprints/ng2/files/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# <%= jsComponentName %> - -This project was generated with [angular-cli](https://github.com/angular/angular-cli) version <%= version %>. - -## 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`. - -## Deploying to Github Pages - -Run `ng github-pages:deploy` to deploy to Github Pages. - -## 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/packages/angular-cli/blueprints/ng2/files/__path__/app/app-routing.module.ts b/packages/angular-cli/blueprints/ng2/files/__path__/app/app-routing.module.ts deleted file mode 100644 index 03f6daa60bec..000000000000 --- a/packages/angular-cli/blueprints/ng2/files/__path__/app/app-routing.module.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { NgModule } from '@angular/core'; -import { Routes, RouterModule } from '@angular/router'; - -const routes: Routes = [ - { - path: '', - children: [] - } -]; - -@NgModule({ - imports: [RouterModule.forRoot(routes)], - exports: [RouterModule], - providers: [] -}) -export class AppRoutingModule { } diff --git a/packages/angular-cli/blueprints/ng2/files/__path__/app/app.component.html b/packages/angular-cli/blueprints/ng2/files/__path__/app/app.component.html deleted file mode 100644 index 4b7217733e92..000000000000 --- a/packages/angular-cli/blueprints/ng2/files/__path__/app/app.component.html +++ /dev/null @@ -1,4 +0,0 @@ -

- {{title}} -

<% if (routing) { %> -<% } %> diff --git a/packages/angular-cli/blueprints/ng2/files/__path__/app/app.component.spec.ts b/packages/angular-cli/blueprints/ng2/files/__path__/app/app.component.spec.ts deleted file mode 100644 index 52587e29d153..000000000000 --- a/packages/angular-cli/blueprints/ng2/files/__path__/app/app.component.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* tslint:disable:no-unused-variable */ - -import { TestBed, async } from '@angular/core/testing';<% if (routing) { %> -import { RouterTestingModule } from '@angular/router/testing';<% } %> -import { AppComponent } from './app.component'; - -describe('AppComponent', () => { - beforeEach(() => { - TestBed.configureTestingModule({<% if (routing) { %> - imports: [ - RouterTestingModule - ],<% } %> - declarations: [ - AppComponent - ], - }); - TestBed.compileComponents(); - }); - - it('should create the app', async(() => { - let fixture = TestBed.createComponent(AppComponent); - let app = fixture.debugElement.componentInstance; - expect(app).toBeTruthy(); - })); - - it(`should have as title '<%= prefix %> works!'`, async(() => { - let fixture = TestBed.createComponent(AppComponent); - let app = fixture.debugElement.componentInstance; - expect(app.title).toEqual('<%= prefix %> works!'); - })); - - it('should render title in a h1 tag', async(() => { - let fixture = TestBed.createComponent(AppComponent); - fixture.detectChanges(); - let compiled = fixture.debugElement.nativeElement; - expect(compiled.querySelector('h1').textContent).toContain('<%= prefix %> works!'); - })); -}); diff --git a/packages/angular-cli/blueprints/ng2/files/__path__/app/app.component.ts b/packages/angular-cli/blueprints/ng2/files/__path__/app/app.component.ts deleted file mode 100644 index f38d4c017945..000000000000 --- a/packages/angular-cli/blueprints/ng2/files/__path__/app/app.component.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Component } from '@angular/core';<% if (isMobile) { %> -import { APP_SHELL_DIRECTIVES } from '@angular/app-shell';<% } %> - -@Component({ - selector: '<%= prefix %>-root',<% if (inlineTemplate) { %> - template: ` -

- {{title}} -

<% if (routing) { %> - <% } %> - `,<% } else { %> - templateUrl: './app.component.html',<% } %><% if (inlineStyle) { %> - styles: []<% } else { %> - styleUrls: ['./app.component.<%= styleExt %>']<% } %> -}) -export class AppComponent { - title = '<%= prefix %> works!'; -} diff --git a/packages/angular-cli/blueprints/ng2/files/__path__/app/app.module.ts b/packages/angular-cli/blueprints/ng2/files/__path__/app/app.module.ts deleted file mode 100644 index 51485d6538e3..000000000000 --- a/packages/angular-cli/blueprints/ng2/files/__path__/app/app.module.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { BrowserModule } from '@angular/platform-browser'; -import { NgModule } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { HttpModule } from '@angular/http';<% if (routing) { %> -import { AppRoutingModule } from './app-routing.module';<% } %> - -import { AppComponent } from './app.component'; - -@NgModule({ - declarations: [ - AppComponent - ], - imports: [ - BrowserModule, - FormsModule, - HttpModule<% if (routing) { %>, - AppRoutingModule<% } %> - ], - providers: [], - bootstrap: [AppComponent] -}) -export class AppModule { } diff --git a/packages/angular-cli/blueprints/ng2/files/__path__/environments/environment.prod.ts b/packages/angular-cli/blueprints/ng2/files/__path__/environments/environment.prod.ts deleted file mode 100644 index 3612073bc31c..000000000000 --- a/packages/angular-cli/blueprints/ng2/files/__path__/environments/environment.prod.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const environment = { - production: true -}; diff --git a/packages/angular-cli/blueprints/ng2/files/__path__/environments/environment.ts b/packages/angular-cli/blueprints/ng2/files/__path__/environments/environment.ts deleted file mode 100644 index 00313f16648e..000000000000 --- a/packages/angular-cli/blueprints/ng2/files/__path__/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/packages/angular-cli/blueprints/ng2/files/__path__/index.html b/packages/angular-cli/blueprints/ng2/files/__path__/index.html deleted file mode 100644 index 1f8c4ad70d2d..000000000000 --- a/packages/angular-cli/blueprints/ng2/files/__path__/index.html +++ /dev/null @@ -1,29 +0,0 @@ - - - - - <%= jsComponentName %> - - - - <% if (isMobile) { %> - - - - - - - - - - - - - - - <% } %> - - - <<%= prefix %>-root>Loading...-root> - - diff --git a/packages/angular-cli/blueprints/ng2/files/__path__/main.ts b/packages/angular-cli/blueprints/ng2/files/__path__/main.ts deleted file mode 100644 index ac78a713c2d1..000000000000 --- a/packages/angular-cli/blueprints/ng2/files/__path__/main.ts +++ /dev/null @@ -1,12 +0,0 @@ -import './polyfills.ts'; - -import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; -import { enableProdMode } from '@angular/core'; -import { environment } from './environments/environment'; -import { AppModule } from './app/app.module'; - -if (environment.production) { - enableProdMode(); -} - -platformBrowserDynamic().bootstrapModule(AppModule); diff --git a/packages/angular-cli/blueprints/ng2/files/__path__/polyfills.ts b/packages/angular-cli/blueprints/ng2/files/__path__/polyfills.ts deleted file mode 100644 index 3b4c55b09656..000000000000 --- a/packages/angular-cli/blueprints/ng2/files/__path__/polyfills.ts +++ /dev/null @@ -1,19 +0,0 @@ -// This file includes polyfills needed by Angular 2 and is loaded before -// the app. You can add your own extra polyfills to this file. -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'; -import 'core-js/es6/reflect'; - -import 'core-js/es7/reflect'; -import 'zone.js/dist/zone'; diff --git a/packages/angular-cli/blueprints/ng2/files/__path__/test.ts b/packages/angular-cli/blueprints/ng2/files/__path__/test.ts deleted file mode 100644 index be44dc587f3a..000000000000 --- a/packages/angular-cli/blueprints/ng2/files/__path__/test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import './polyfills.ts'; - -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. -let 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/packages/angular-cli/blueprints/ng2/files/__path__/tsconfig.json b/packages/angular-cli/blueprints/ng2/files/__path__/tsconfig.json deleted file mode 100644 index fa3fcc8abcee..000000000000 --- a/packages/angular-cli/blueprints/ng2/files/__path__/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "baseUrl": "", - "declaration": false, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "lib": ["es6", "dom"], - "mapRoot": "./", - "module": "es6", - "moduleResolution": "node", - "outDir": "<%= relativeRootPath %>/dist/out-tsc", - "sourceMap": true, - "target": "es5", - "typeRoots": [ - "<%= relativeRootPath %>/node_modules/@types" - ] - } -} diff --git a/packages/angular-cli/blueprints/ng2/files/angular-cli.json b/packages/angular-cli/blueprints/ng2/files/angular-cli.json deleted file mode 100644 index 4fd88b98c1cf..000000000000 --- a/packages/angular-cli/blueprints/ng2/files/angular-cli.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "project": { - "version": "<%= version %>", - "name": "<%= htmlComponentName %>" - }, - "apps": [ - { - "root": "<%= sourceDir %>", - "outDir": "dist", - "assets": [ - "assets", - "favicon.ico" - ], - "index": "index.html", - "main": "main.ts", - "test": "test.ts", - "tsconfig": "tsconfig.json", - "prefix": "<%= prefix %>", - "mobile": <%= isMobile %>, - "styles": [ - "styles.<%= styleExt %>" - ], - "scripts": [], - "environments": { - "source": "environments/environment.ts", - "dev": "environments/environment.ts", - "prod": "environments/environment.prod.ts" - } - } - ], - "addons": [], - "packages": [], - "e2e": { - "protractor": { - "config": "./protractor.conf.js" - } - }, - "test": { - "karma": { - "config": "./karma.conf.js" - } - }, - "defaults": { - "styleExt": "<%= styleExt %>", - "prefixInterfaces": false, - "inline": { - "style": false, - "template": false - }, - "spec": { - "class": false, - "component": true, - "directive": true, - "module": false, - "pipe": true, - "service": true - } - } -} diff --git a/packages/angular-cli/blueprints/ng2/files/e2e/app.e2e-spec.ts b/packages/angular-cli/blueprints/ng2/files/e2e/app.e2e-spec.ts deleted file mode 100644 index 2d9a848286c8..000000000000 --- a/packages/angular-cli/blueprints/ng2/files/e2e/app.e2e-spec.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { <%= jsComponentName %>Page } from './app.po'; - -describe('<%= htmlComponentName %> App', function() { - let page: <%= jsComponentName %>Page; - - beforeEach(() => { - page = new <%= jsComponentName %>Page(); - }); - - it('should display message saying app works', () => { - page.navigateTo(); - expect(page.getParagraphText()).toEqual('<%= prefix %> works!'); - }); -}); diff --git a/packages/angular-cli/blueprints/ng2/files/e2e/app.po.ts b/packages/angular-cli/blueprints/ng2/files/e2e/app.po.ts deleted file mode 100644 index 9008b7bec9b0..000000000000 --- a/packages/angular-cli/blueprints/ng2/files/e2e/app.po.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { browser, element, by } from 'protractor'; - -export class <%= jsComponentName %>Page { - navigateTo() { - return browser.get('/'); - } - - getParagraphText() { - return element(by.css('<%= prefix %>-root h1')).getText(); - } -} diff --git a/packages/angular-cli/blueprints/ng2/files/e2e/tsconfig.json b/packages/angular-cli/blueprints/ng2/files/e2e/tsconfig.json deleted file mode 100644 index 656bdb14ff14..000000000000 --- a/packages/angular-cli/blueprints/ng2/files/e2e/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compileOnSave": false, - "compilerOptions": { - "declaration": false, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "module": "commonjs", - "moduleResolution": "node", - "outDir": "../dist/out-tsc-e2e", - "sourceMap": true, - "target": "es5", - "typeRoots": [ - "../node_modules/@types" - ] - } -} diff --git a/packages/angular-cli/blueprints/ng2/files/gitignore b/packages/angular-cli/blueprints/ng2/files/gitignore deleted file mode 100755 index fe6f8a76b049..000000000000 --- a/packages/angular-cli/blueprints/ng2/files/gitignore +++ /dev/null @@ -1,40 +0,0 @@ -# See http://help.github.com/ignore-files/ for more about ignoring files. - -# compiled output -/dist -/tmp - -# dependencies -/node_modules - -# IDEs and editors -/.idea -.project -.classpath -.c9/ -*.launch -.settings/ - -# 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/packages/angular-cli/blueprints/ng2/files/karma.conf.js b/packages/angular-cli/blueprints/ng2/files/karma.conf.js deleted file mode 100644 index 2ec744bb0530..000000000000 --- a/packages/angular-cli/blueprints/ng2/files/karma.conf.js +++ /dev/null @@ -1,43 +0,0 @@ -// 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-remap-istanbul'), - require('angular-cli/plugins/karma') - ], - files: [ - { pattern: './<%= sourceDir %>/test.ts', watched: false } - ], - preprocessors: { - './<%= sourceDir %>/test.ts': ['angular-cli'] - }, - mime: { - 'text/x-typescript': ['ts','tsx'] - }, - remapIstanbulReporter: { - reports: { - html: 'coverage', - lcovonly: './coverage/coverage.lcov' - } - }, - angularCli: { - config: './angular-cli.json', - environment: 'dev' - }, - reporters: config.angularCli && config.angularCli.codeCoverage - ? ['progress', 'karma-remap-istanbul'] - : ['progress'], - port: 9876, - colors: true, - logLevel: config.LOG_INFO, - autoWatch: true, - browsers: ['Chrome'], - singleRun: false - }); -}; diff --git a/packages/angular-cli/blueprints/ng2/files/package.json b/packages/angular-cli/blueprints/ng2/files/package.json deleted file mode 100644 index 28de1005ae14..000000000000 --- a/packages/angular-cli/blueprints/ng2/files/package.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "name": "<%= htmlComponentName %>", - "version": "0.0.0", - "license": "MIT", - "angular-cli": {}, - "scripts": { - "ng": "ng", - "start": "ng serve", - "lint": "tslint \"<%= sourceDir %>/**/*.ts\"", - "test": "ng test", - "pree2e": "webdriver-manager update --standalone false --gecko false", - "e2e": "protractor" - }, - "private": true, - "dependencies": { - "@angular/common": "^2.3.1", - "@angular/compiler": "^2.3.1", - "@angular/core": "^2.3.1", - "@angular/forms": "^2.3.1", - "@angular/http": "^2.3.1", - "@angular/platform-browser": "^2.3.1", - "@angular/platform-browser-dynamic": "^2.3.1", - "@angular/router": "^3.3.1", - "core-js": "^2.4.1", - "rxjs": "^5.0.1", - "ts-helpers": "^1.1.1", - "zone.js": "^0.7.2" - }, - "devDependencies": { - "@angular/compiler-cli": "^2.3.1", - "@types/jasmine": "2.5.38", - "@types/node": "^6.0.42", - "angular-cli": "<%= version %>", - "codelyzer": "~2.0.0-beta.1", - "jasmine-core": "2.5.2", - "jasmine-spec-reporter": "2.5.0", - "karma": "1.2.0", - "karma-chrome-launcher": "^2.0.0", - "karma-cli": "^1.0.1", - "karma-jasmine": "^1.0.2", - "karma-remap-istanbul": "^0.2.1", - "protractor": "~4.0.13", - "ts-node": "1.2.1", - "tslint": "^4.0.2", - "typescript": "~2.0.3" - } -} diff --git a/packages/angular-cli/blueprints/ng2/files/protractor.conf.js b/packages/angular-cli/blueprints/ng2/files/protractor.conf.js deleted file mode 100644 index ffded70180a2..000000000000 --- a/packages/angular-cli/blueprints/ng2/files/protractor.conf.js +++ /dev/null @@ -1,32 +0,0 @@ -// Protractor configuration file, see link for more information -// https://github.com/angular/protractor/blob/master/lib/config.ts - -/*global jasmine */ -var 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() {} - }, - useAllAngular2AppRoots: true, - beforeLaunch: function() { - require('ts-node').register({ - project: 'e2e' - }); - }, - onPrepare: function() { - jasmine.getEnv().addReporter(new SpecReporter()); - } -}; diff --git a/packages/angular-cli/blueprints/ng2/files/tslint.json b/packages/angular-cli/blueprints/ng2/files/tslint.json deleted file mode 100644 index 3eabe8b7122a..000000000000 --- a/packages/angular-cli/blueprints/ng2/files/tslint.json +++ /dev/null @@ -1,107 +0,0 @@ -{ - "rulesDirectory": [ - "node_modules/codelyzer" - ], - "rules": { - "class-name": true, - "comment-format": [ - true, - "check-space" - ], - "curly": true, - "eofline": true, - "forin": true, - "indent": [ - true, - "spaces" - ], - "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-eval": true, - "no-inferrable-types": true, - "no-shadowed-variable": true, - "no-string-literal": false, - "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" - ], - "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" - } - ], - "variable-name": false, - "whitespace": [ - true, - "check-branch", - "check-decl", - "check-operator", - "check-separator", - "check-type" - ], - - "directive-selector": [true, "attribute", "<%= prefix %>", "camelCase"], - "component-selector": [true, "element", "<%= prefix %>", "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/packages/angular-cli/blueprints/ng2/index.js b/packages/angular-cli/blueprints/ng2/index.js deleted file mode 100644 index 3ab5dd53ea42..000000000000 --- a/packages/angular-cli/blueprints/ng2/index.js +++ /dev/null @@ -1,90 +0,0 @@ -const Blueprint = require('../../ember-cli/lib/models/blueprint'); -const path = require('path'); -const stringUtils = require('ember-cli-string-utils'); -const getFiles = Blueprint.prototype.files; - -module.exports = { - description: '', - - availableOptions: [ - { name: 'source-dir', type: String, default: 'src', aliases: ['sd'] }, - { name: 'prefix', type: String, default: 'app', aliases: ['p'] }, - { name: 'style', type: String, default: 'css' }, - { name: 'mobile', type: Boolean, default: false }, - { name: 'routing', type: Boolean, default: false }, - { name: 'inline-style', type: Boolean, default: false, aliases: ['is'] }, - { name: 'inline-template', type: Boolean, default: false, aliases: ['it'] } - ], - - beforeInstall: function(options) { - if (options.ignoredUpdateFiles && options.ignoredUpdateFiles.length > 0) { - return Blueprint.ignoredUpdateFiles = Blueprint.ignoredUpdateFiles.concat(options.ignoredUpdateFiles); - } - }, - - afterInstall: function (options) { - if (options.mobile) { - return Blueprint.load(path.join(__dirname, '../mobile')).install(options); - } - }, - - locals: function(options) { - this.styleExt = options.style; - this.version = require(path.resolve(__dirname, '../../package.json')).version; - - // Split/join with / not path.sep as reference to typings require forward slashes. - const relativeRootPath = options.sourceDir.split('/').map(() => '..').join('/'); - const fullAppName = stringUtils.dasherize(options.entity.name) - .replace(/-(.)/g, (_, l) => ' ' + l.toUpperCase()) - .replace(/^./, (l) => l.toUpperCase()); - - // For mobile projects, force inline styles and templates. - if (options.mobile) { - options.inlineStyle = true; - options.inlineTemplate = true; - } - - return { - htmlComponentName: stringUtils.dasherize(options.entity.name), - jsComponentName: stringUtils.classify(options.entity.name), - fullAppName: fullAppName, - version: this.version, - sourceDir: options.sourceDir, - prefix: options.prefix, - styleExt: this.styleExt, - relativeRootPath: relativeRootPath, - isMobile: options.mobile, - routing: options.routing, - inlineStyle: options.inlineStyle, - inlineTemplate: options.inlineTemplate - }; - }, - - files: function() { - var fileList = getFiles.call(this); - - if (this.options && !this.options.routing) { - fileList = fileList.filter(p => p.indexOf('app-routing.module.ts') < 0); - } - if (this.options && this.options.inlineTemplate) { - fileList = fileList.filter(p => p.indexOf('app.component.html') < 0); - } - if (this.options && this.options.inlineStyle) { - fileList = fileList.filter(p => p.indexOf('app.component.__styleext__') < 0); - } - - return fileList; - }, - - fileMapTokens: function (options) { - // Return custom template variables here. - return { - __path__: () => { - return options.locals.sourceDir; - }, - __styleext__: () => { - return this.styleExt; - } - }; - } -}; diff --git a/packages/angular-cli/blueprints/pipe/files/__path__/__name__.pipe.spec.ts b/packages/angular-cli/blueprints/pipe/files/__path__/__name__.pipe.spec.ts deleted file mode 100644 index 7d0cee51aae8..000000000000 --- a/packages/angular-cli/blueprints/pipe/files/__path__/__name__.pipe.spec.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* tslint:disable:no-unused-variable */ - -import { TestBed, async } from '@angular/core/testing'; -import { <%= classifiedModuleName %>Pipe } from './<%= dasherizedModuleName %>.pipe'; - -describe('<%= classifiedModuleName %>Pipe', () => { - it('create an instance', () => { - let pipe = new <%= classifiedModuleName %>Pipe(); - expect(pipe).toBeTruthy(); - }); -}); diff --git a/packages/angular-cli/blueprints/pipe/files/__path__/__name__.pipe.ts b/packages/angular-cli/blueprints/pipe/files/__path__/__name__.pipe.ts deleted file mode 100644 index 2813a99446ed..000000000000 --- a/packages/angular-cli/blueprints/pipe/files/__path__/__name__.pipe.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Pipe, PipeTransform } from '@angular/core'; - -@Pipe({ - name: '<%= camelizedModuleName %>' -}) -export class <%= classifiedModuleName %>Pipe implements PipeTransform { - - transform(value: any, args?: any): any { - return null; - } - -} diff --git a/packages/angular-cli/blueprints/pipe/index.js b/packages/angular-cli/blueprints/pipe/index.js deleted file mode 100644 index d1a17144dc81..000000000000 --- a/packages/angular-cli/blueprints/pipe/index.js +++ /dev/null @@ -1,94 +0,0 @@ -const path = require('path'); -const chalk = require('chalk'); -const dynamicPathParser = require('../../utilities/dynamic-path-parser'); -const stringUtils = require('ember-cli-string-utils'); -const astUtils = require('../../utilities/ast-utils'); -const findParentModule = require('../../utilities/find-parent-module').default; -const NodeHost = require('@angular-cli/ast-tools').NodeHost; -const Blueprint = require('../../ember-cli/lib/models/blueprint'); -const getFiles = Blueprint.prototype.files; - -module.exports = { - description: '', - - availableOptions: [ - { name: 'flat', type: Boolean, default: true }, - { name: 'spec', type: Boolean }, - { name: 'skip-import', type: Boolean, default: false } - ], - - beforeInstall: function(options) { - try { - this.pathToModule = findParentModule(this.project, this.dynamicPath.dir); - } catch(e) { - if (!options.skipImport) { - throw `Error locating module for declaration\n\t${e}`; - } - } - }, - - normalizeEntityName: function (entityName) { - var parsedPath = dynamicPathParser(this.project, entityName); - - this.dynamicPath = parsedPath; - return parsedPath.name; - }, - - locals: function (options) { - options.spec = options.spec !== undefined ? - options.spec : - this.project.ngConfigObj.get('defaults.spec.pipe'); - - return { - dynamicPath: this.dynamicPath.dir, - flat: options.flat - }; - }, - - files: function() { - var fileList = getFiles.call(this); - - if (this.options && !this.options.spec) { - fileList = fileList.filter(p => p.indexOf('__name__.pipe.spec.ts') < 0); - } - - return fileList; - }, - - fileMapTokens: function (options) { - // Return custom template variables here. - return { - __path__: () => { - var dir = this.dynamicPath.dir; - if (!options.locals.flat) { - dir += path.sep + options.dasherizedModuleName; - } - this.generatePath = dir; - return dir; - } - }; - }, - - afterInstall: function(options) { - if (options.dryRun) { - return; - } - - const returns = []; - const className = stringUtils.classify(`${options.entity.name}Pipe`); - const fileName = stringUtils.dasherize(`${options.entity.name}.pipe`); - const fullGeneratePath = path.join(this.project.root, this.generatePath); - const moduleDir = path.parse(this.pathToModule).dir; - const relativeDir = path.relative(moduleDir, fullGeneratePath); - const importPath = relativeDir ? `./${relativeDir}/${fileName}` : `./${fileName}`; - - if (!options.skipImport) { - returns.push( - astUtils.addDeclarationToModule(this.pathToModule, className, importPath) - .then(change => change.apply(NodeHost))); - this._writeStatusToUI(chalk.yellow, 'update', path.relative(this.project.root, this.pathToModule)); - } - - return Promise.all(returns); - } -}; diff --git a/packages/angular-cli/blueprints/service/files/__path__/__name__.service.spec.ts b/packages/angular-cli/blueprints/service/files/__path__/__name__.service.spec.ts deleted file mode 100644 index f1a12e7ab3d6..000000000000 --- a/packages/angular-cli/blueprints/service/files/__path__/__name__.service.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* tslint:disable:no-unused-variable */ - -import { TestBed, async, inject } from '@angular/core/testing'; -import { <%= classifiedModuleName %>Service } from './<%= dasherizedModuleName %>.service'; - -describe('<%= classifiedModuleName %>Service', () => { - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [<%= classifiedModuleName %>Service] - }); - }); - - it('should ...', inject([<%= classifiedModuleName %>Service], (service: <%= classifiedModuleName %>Service) => { - expect(service).toBeTruthy(); - })); -}); diff --git a/packages/angular-cli/blueprints/service/files/__path__/__name__.service.ts b/packages/angular-cli/blueprints/service/files/__path__/__name__.service.ts deleted file mode 100644 index 95fc61a65e59..000000000000 --- a/packages/angular-cli/blueprints/service/files/__path__/__name__.service.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Injectable } from '@angular/core'; - -@Injectable() -export class <%= classifiedModuleName %>Service { - - constructor() { } - -} diff --git a/packages/angular-cli/blueprints/service/index.js b/packages/angular-cli/blueprints/service/index.js deleted file mode 100644 index bcdf691a6372..000000000000 --- a/packages/angular-cli/blueprints/service/index.js +++ /dev/null @@ -1,61 +0,0 @@ -const path = require('path'); -const chalk = require('chalk'); -const dynamicPathParser = require('../../utilities/dynamic-path-parser'); -const Blueprint = require('../../ember-cli/lib/models/blueprint'); -const getFiles = Blueprint.prototype.files; - -module.exports = { - description: '', - - availableOptions: [ - { name: 'flat', type: Boolean, default: true }, - { name: 'spec', type: Boolean } - ], - - normalizeEntityName: function (entityName) { - var parsedPath = dynamicPathParser(this.project, entityName); - - this.dynamicPath = parsedPath; - return parsedPath.name; - }, - - locals: function (options) { - options.spec = options.spec !== undefined ? - options.spec : - this.project.ngConfigObj.get('defaults.spec.service'); - - return { - dynamicPath: this.dynamicPath.dir, - flat: options.flat - }; - }, - - files: function() { - var fileList = getFiles.call(this); - - if (this.options && !this.options.spec) { - fileList = fileList.filter(p => p.indexOf('__name__.service.spec.ts') < 0); - } - - return fileList; - }, - - fileMapTokens: function (options) { - // Return custom template variables here. - return { - __path__: () => { - var dir = this.dynamicPath.dir; - if (!options.locals.flat) { - dir += path.sep + options.dasherizedModuleName; - } - this.generatePath = dir; - return dir; - } - }; - }, - - afterInstall() { - const warningMessage = 'Service is generated but not provided, it must be provided to be used'; - this._writeStatusToUI(chalk.yellow, 'WARNING', warningMessage); - } -}; diff --git a/packages/angular-cli/commands/build.ts b/packages/angular-cli/commands/build.ts deleted file mode 100644 index 6584be61078d..000000000000 --- a/packages/angular-cli/commands/build.ts +++ /dev/null @@ -1,90 +0,0 @@ -import {Version} from '../upgrade/version'; -const Command = require('../ember-cli/lib/models/command'); -import WebpackBuild from '../tasks/build-webpack'; -import WebpackBuildWatch from '../tasks/build-webpack-watch'; - -export interface BuildOptions { - target?: string; - environment?: string; - outputPath?: string; - watch?: boolean; - watcher?: string; - supressSizes: boolean; - baseHref?: string; - aot?: boolean; - sourcemap?: boolean; - vendorChunk?: boolean; - verbose?: boolean; - progress?: boolean; - i18nFile?: string; - i18nFormat?: string; - locale?: string; -} - -const BuildCommand = Command.extend({ - name: 'build', - description: 'Builds your app and places it into the output path (dist/ by default).', - aliases: ['b'], - - availableOptions: [ - { - name: 'target', - type: String, - default: 'development', - aliases: ['t', { 'dev': 'development' }, { 'prod': 'production' }] - }, - { name: 'environment', type: String, default: '', aliases: ['e'] }, - { name: 'output-path', type: 'Path', default: null, aliases: ['o'] }, - { name: 'watch', type: Boolean, default: false, aliases: ['w'] }, - { name: 'watcher', type: String }, - { name: 'suppress-sizes', type: Boolean, default: false }, - { name: 'base-href', type: String, default: null, aliases: ['bh'] }, - { name: 'aot', type: Boolean, default: false }, - { name: 'sourcemap', type: Boolean, default: true, aliases: ['sm'] }, - { name: 'vendor-chunk', type: Boolean, default: true }, - { name: 'verbose', type: Boolean, default: false }, - { name: 'progress', type: Boolean, default: true }, - { name: 'i18n-file', type: String, default: null }, - { name: 'i18n-format', type: String, default: null }, - { name: 'locale', type: String, default: null } - ], - - run: function (commandOptions: BuildOptions) { - if (commandOptions.environment === '') { - if (commandOptions.target === 'development') { - commandOptions.environment = 'dev'; - } - if (commandOptions.target === 'production') { - commandOptions.environment = 'prod'; - } - } - - const project = this.project; - - // Check angular version. - Version.assertAngularVersionIs2_3_1OrHigher(project.root); - - const ui = this.ui; - const buildTask = commandOptions.watch ? - new WebpackBuildWatch({ - cliProject: project, - ui: ui, - outputPath: commandOptions.outputPath, - target: commandOptions.target, - environment: commandOptions.environment - }) : - new WebpackBuild({ - cliProject: project, - ui: ui, - outputPath: commandOptions.outputPath, - target: commandOptions.target, - environment: commandOptions.environment, - }); - - return buildTask.run(commandOptions); - } -}); - - -BuildCommand.overrideCore = true; -export default BuildCommand; diff --git a/packages/angular-cli/commands/completion.ts b/packages/angular-cli/commands/completion.ts deleted file mode 100644 index 0a272ad996b0..000000000000 --- a/packages/angular-cli/commands/completion.ts +++ /dev/null @@ -1,18 +0,0 @@ -import * as path from 'path'; -import * as fs from 'fs'; - -const Command = require('../ember-cli/lib/models/command'); - -const CompletionCommand = Command.extend({ - name: 'completion', - description: 'Adds autocomplete functionality to `ng` commands and subcommands', - works: 'everywhere', - run: function() { - const scriptPath = path.resolve(__dirname, '..', 'utilities', 'completion.sh'); - const scriptOutput = fs.readFileSync(scriptPath, 'utf8'); - - console.log(scriptOutput); - } -}); - -export default CompletionCommand; diff --git a/packages/angular-cli/commands/destroy.ts b/packages/angular-cli/commands/destroy.ts deleted file mode 100644 index 57745599d2fe..000000000000 --- a/packages/angular-cli/commands/destroy.ts +++ /dev/null @@ -1,20 +0,0 @@ -const Command = require('../ember-cli/lib/models/command'); -const SilentError = require('silent-error'); - - -const DestroyCommand = Command.extend({ - name: 'destroy', - aliases: ['d'], - works: 'insideProject', - - anonymousOptions: [ - '' - ], - - run: function() { - return Promise.reject(new SilentError('The destroy command is not supported by Angular-CLI.')); - } -}); - -export default DestroyCommand; -DestroyCommand.overrideCore = true; diff --git a/packages/angular-cli/commands/doc.ts b/packages/angular-cli/commands/doc.ts deleted file mode 100644 index 2b055acfebca..000000000000 --- a/packages/angular-cli/commands/doc.ts +++ /dev/null @@ -1,25 +0,0 @@ -const Command = require('../ember-cli/lib/models/command'); -import { DocTask } from '../tasks/doc'; - -const DocCommand = Command.extend({ - name: 'doc', - description: 'Opens the official Angular documentation for a given keyword.', - works: 'everywhere', - - anonymousOptions: [ - '' - ], - - run: function(commandOptions: any, rawArgs: Array) { - const keyword = rawArgs[0]; - - const docTask = new DocTask({ - ui: this.ui, - project: this.project - }); - - return docTask.run(keyword); - } -}); - -export default DocCommand; diff --git a/packages/angular-cli/commands/e2e.ts b/packages/angular-cli/commands/e2e.ts deleted file mode 100644 index 5099354429eb..000000000000 --- a/packages/angular-cli/commands/e2e.ts +++ /dev/null @@ -1,22 +0,0 @@ -const Command = require('../ember-cli/lib/models/command'); -import {E2eTask} from '../tasks/e2e'; -import {CliConfig} from '../models/config'; - -const E2eCommand = Command.extend({ - name: 'e2e', - description: 'Run e2e tests in existing project', - works: 'insideProject', - run: function () { - this.project.ngConfig = this.project.ngConfig || CliConfig.fromProject(); - - const e2eTask = new E2eTask({ - ui: this.ui, - project: this.project - }); - - return e2eTask.run(); - } -}); - - -export default E2eCommand; diff --git a/packages/angular-cli/commands/easter-egg.ts b/packages/angular-cli/commands/easter-egg.ts deleted file mode 100644 index 7cae2cfecb6c..000000000000 --- a/packages/angular-cli/commands/easter-egg.ts +++ /dev/null @@ -1,34 +0,0 @@ -const Command = require('../ember-cli/lib/models/command'); -const stringUtils = require('ember-cli-string-utils'); -import * as chalk from 'chalk'; - - -function pickOne(of: string[]): string { - return of[Math.floor(Math.random() * of.length)]; -} - - -const MakeThisAwesomeCommand = Command.extend({ - name: 'make-this-awesome', - works: 'insideProject', - - run: function (commandOptions: any, rawArgs: string[]): Promise { - (this as any)[stringUtils.camelize(this.name)](commandOptions, rawArgs); - - return Promise.resolve(); - }, - - makeThisAwesome: function() { - const phrase = pickOne([ - `You're on it, there's nothing for me to do!`, - `Let's take a look... nope, it's all good!`, - `You're doing fine.`, - `You're already doing great.`, - `Nothing to do; already awesome. Exiting.`, - `Error 418: As Awesome As Can Get.` - ]); - console.log(chalk.green(phrase)); - } -}); - -export default MakeThisAwesomeCommand; diff --git a/packages/angular-cli/commands/generate.ts b/packages/angular-cli/commands/generate.ts deleted file mode 100644 index 94adafed29c8..000000000000 --- a/packages/angular-cli/commands/generate.ts +++ /dev/null @@ -1,71 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; - -const chalk = require('chalk'); -const EmberGenerateCommand = require('../ember-cli/lib/commands/generate'); -const Blueprint = require('../ember-cli/lib/models/blueprint'); -const SilentError = require('silent-error'); - - -const GenerateCommand = EmberGenerateCommand.extend({ - name: 'generate', - - beforeRun: function(rawArgs: string[]) { - if (!rawArgs.length) { - return; - } - - // map the blueprint name to allow for aliases - rawArgs[0] = mapBlueprintName(rawArgs[0]); - - if (rawArgs[0] !== '--help' && - !fs.existsSync(path.join(__dirname, '..', 'blueprints', rawArgs[0]))) { - SilentError.debugOrThrow('angular-cli/commands/generate', `Invalid blueprint: ${rawArgs[0]}`); - } - - if (!rawArgs[1]) { - SilentError.debugOrThrow('angular-cli/commands/generate', - `The \`ng generate ${rawArgs[0]}\` command requires a name to be specified.`); - } - - // Override default help to hide ember blueprints - EmberGenerateCommand.prototype.printDetailedHelp = function() { - const blueprintList = fs.readdirSync(path.join(__dirname, '..', 'blueprints')); - const blueprints = blueprintList - .filter(bp => bp.indexOf('-test') === -1) - .filter(bp => bp !== 'ng2') - .filter(bp => bp !== 'mobile') - .map(bp => Blueprint.load(path.join(__dirname, '..', 'blueprints', bp))); - - let output = ''; - blueprints - .forEach(function (bp) { - output += bp.printBasicHelp(false) + os.EOL; - }); - this.ui.writeLine(chalk.cyan(' Available blueprints')); - this.ui.writeLine(output); - }; - - return EmberGenerateCommand.prototype.beforeRun.apply(this, arguments); - } -}); - -function mapBlueprintName(name: string): string { - let mappedName: string = aliasMap[name]; - return mappedName ? mappedName : name; -} - -const aliasMap: { [alias: string]: string } = { - 'cl': 'class', - 'c': 'component', - 'd': 'directive', - 'e': 'enum', - 'm': 'module', - 'p': 'pipe', - 'r': 'route', - 's': 'service' -}; - -export default GenerateCommand; -GenerateCommand.overrideCore = true; diff --git a/packages/angular-cli/commands/get.ts b/packages/angular-cli/commands/get.ts deleted file mode 100644 index f567f76809e8..000000000000 --- a/packages/angular-cli/commands/get.ts +++ /dev/null @@ -1,30 +0,0 @@ -import * as chalk from 'chalk'; -import {CliConfig} from '../models/config'; - -const Command = require('../ember-cli/lib/models/command'); - -const GetCommand = Command.extend({ - name: 'get', - description: 'Get a value from the configuration.', - works: 'everywhere', - - availableOptions: [], - - run: function (commandOptions: any, rawArgs: string[]): Promise { - return new Promise(resolve => { - const config = CliConfig.fromProject(); - const value = config.get(rawArgs[0]); - - if (value === null) { - console.error(chalk.red('Value cannot be found.')); - } else if (typeof value == 'object') { - console.log(JSON.stringify(value)); - } else { - console.log(value); - } - resolve(); - }); - } -}); - -export default GetCommand; diff --git a/packages/angular-cli/commands/github-pages-deploy.ts b/packages/angular-cli/commands/github-pages-deploy.ts deleted file mode 100644 index 9951f13ecd4d..000000000000 --- a/packages/angular-cli/commands/github-pages-deploy.ts +++ /dev/null @@ -1,288 +0,0 @@ -const Command = require('../ember-cli/lib/models/command'); -const SilentError = require('silent-error'); -import denodeify = require('denodeify'); - -import { exec } from 'child_process'; -import * as chalk from 'chalk'; -import * as fs from 'fs'; -import * as fse from 'fs-extra'; -import * as path from 'path'; -import WebpackBuild from '../tasks/build-webpack'; -import CreateGithubRepo from '../tasks/create-github-repo'; -import { CliConfig } from '../models/config'; -import { oneLine } from 'common-tags'; - -const fsReadDir = denodeify(fs.readdir); -const fsCopy = denodeify(fse.copy); - -interface GithubPagesDeployOptions { - message?: string; - target?: string; - environment?: string; - userPage?: boolean; - skipBuild?: boolean; - ghToken?: string; - ghUsername?: string; - baseHref?: string; -} - -const githubPagesDeployCommand = Command.extend({ - name: 'github-pages:deploy', - aliases: ['gh-pages:deploy'], - description: oneLine` - Build the test app for production, commit it into a git branch, - setup GitHub repo and push to it - `, - works: 'insideProject', - - availableOptions: [ - { - name: 'message', - type: String, - default: 'new gh-pages version', - description: 'The commit message to include with the build, must be wrapped in quotes.' - }, { - name: 'target', - type: String, - default: 'production', - aliases: ['t', { 'dev': 'development' }, { 'prod': 'production' }] - }, { - name: 'environment', - type: String, - default: '', - aliases: ['e'] - }, { - name: 'user-page', - type: Boolean, - default: false, - description: 'Deploy as a user/org page' - }, { - name: 'skip-build', - type: Boolean, - default: false, - description: 'Skip building the project before deploying' - }, { - name: 'gh-token', - type: String, - default: '', - description: 'Github token' - }, { - name: 'gh-username', - type: String, - default: '', - description: 'Github username' - }, { - name: 'base-href', - type: String, - default: null, - aliases: ['bh'] - }], - - run: function(options: GithubPagesDeployOptions, rawArgs: string[]) { - const ui = this.ui; - const root = this.project.root; - const execOptions = { - cwd: root - }; - - if (options.environment === '') { - if (options.target === 'development') { - options.environment = 'dev'; - } - if (options.target === 'production') { - options.environment = 'prod'; - } - } - - const projectName = this.project.pkg.name; - - const outDir = CliConfig.fromProject().config.apps[0].outDir; - const indexFilename = CliConfig.fromProject().config.apps[0].index; - - let ghPagesBranch = 'gh-pages'; - let destinationBranch = options.userPage ? 'master' : ghPagesBranch; - let initialBranch: string; - let branchErrMsg = ' You might also need to return to the initial branch manually.'; - - // declared here so that tests can stub exec - const execPromise = <(cmd: string, options?: any) => Promise>denodeify(exec); - - const buildTask = new WebpackBuild({ - ui: this.ui, - cliProject: this.project, - target: options.target, - environment: options.environment, - outputPath: outDir - }); - - /** - * BaseHref tag setting logic: - * First, use --base-href flag value if provided. - * Else if --user-page is true, then keep baseHref default as declared in index.html. - * Otherwise auto-replace with `/${projectName}/`. - */ - const baseHref = options.baseHref || (options.userPage ? null : `/${projectName}/`); - - const buildOptions = { - target: options.target, - environment: options.environment, - outputPath: outDir, - baseHref: baseHref, - }; - - const createGithubRepoTask = new CreateGithubRepo({ - ui: this.ui, - project: this.project - }); - - const createGithubRepoOptions = { - projectName, - ghUsername: options.ghUsername, - ghToken: options.ghToken - }; - - return checkForPendingChanges() - .then(build) - .then(saveStartingBranchName) - .then(createGitHubRepoIfNeeded) - .then(checkoutGhPages) - .then(cleanGhPagesBranch) - .then(copyFiles) - .then(createNotFoundPage) - .then(addAndCommit) - .then(returnStartingBranch) - .then(pushToGitRepo) - .then(printProjectUrl) - .catch(failGracefully); - - function checkForPendingChanges() { - return execPromise('git status --porcelain') - .then((stdout: string) => { - if (/\w+/m.test(stdout)) { - let msg = 'Uncommitted file changes found! Please commit all changes before deploying.'; - return Promise.reject(new SilentError(msg)); - } - }); - } - - function build() { - if (options.skipBuild) { return Promise.resolve(); } - return buildTask.run(buildOptions); - } - - function saveStartingBranchName() { - return execPromise('git rev-parse --abbrev-ref HEAD') - .then((stdout: string) => initialBranch = stdout.replace(/\s/g, '')); - } - - function createGitHubRepoIfNeeded() { - return execPromise('git remote -v') - .then(function(stdout) { - if (!/origin\s+(https:\/\/|git@)github\.com/m.test(stdout)) { - return createGithubRepoTask.run(createGithubRepoOptions) - .then(() => { - // only push starting branch if it's not the destinationBranch - // this happens commonly when using github user pages, since - // they require the destination branch to be 'master' - if (destinationBranch !== initialBranch) { - execPromise(`git push -u origin ${initialBranch}`); - } - }); - } - }); - } - - function checkoutGhPages() { - return execPromise(`git checkout ${ghPagesBranch}`) - .catch(createGhPagesBranch); - } - - function createGhPagesBranch() { - return execPromise(`git checkout --orphan ${ghPagesBranch}`) - .then(() => execPromise('git rm --cached -r .', execOptions)) - .then(() => execPromise('git add .gitignore', execOptions)) - .then(() => execPromise('git clean -f -d', execOptions)) - .then(() => execPromise(`git commit -m \"initial ${ghPagesBranch} commit\"`)); - } - - function cleanGhPagesBranch() { - return execPromise('git ls-files') - .then(function(stdout) { - let files = ''; - stdout.split(/\n/).forEach(function(f) { - // skip .gitignore & 404.html - if (( f != '') && (f != '.gitignore') && (f != '404.html')) { - files = files.concat(`"${f}" `); - } - }); - return execPromise(`git rm -r ${files}`) - .catch(() => { - // Ignoring errors when trying to erase files. - }); - }); - } - - function copyFiles() { - return fsReadDir(outDir) - .then((files: string[]) => Promise.all(files.map((file) => { - if (file === '.gitignore') { - // don't overwrite the .gitignore file - return Promise.resolve(); - } - return fsCopy(path.join(outDir, file), path.join('.', file)); - }))); - } - - function createNotFoundPage() { - const indexHtml = path.join(root, indexFilename); - const notFoundPage = path.join(root, '404.html'); - return fsCopy(indexHtml, notFoundPage); - } - - function addAndCommit() { - return execPromise('git add .', execOptions) - .then(() => execPromise(`git commit -m "${options.message}"`)) - .catch(() => { - let msg = 'No changes found. Deployment skipped.'; - return returnStartingBranch() - .then(() => Promise.reject(new SilentError(msg))) - .catch(() => Promise.reject(new SilentError(msg.concat(branchErrMsg)))); - }); - } - - function returnStartingBranch() { - return execPromise(`git checkout ${initialBranch}`); - } - - function pushToGitRepo() { - return execPromise(`git push origin ${ghPagesBranch}:${destinationBranch}`) - .catch((err) => returnStartingBranch() - .catch(() => Promise.reject(err) )); - } - - function printProjectUrl() { - return execPromise('git remote -v') - .then((stdout) => { - let match = stdout.match(/origin\s+(?:https:\/\/|git@)github\.com(?:\:|\/)([^\/]+)/m); - let userName = match[1].toLowerCase(); - let url = `https://${userName}.github.io/${options.userPage ? '' : (baseHref + '/')}`; - ui.writeLine(chalk.green(`Deployed! Visit ${url}`)); - ui.writeLine('Github pages might take a few minutes to show the deployed site.'); - }); - } - - function failGracefully(error: Error) { - if (error && (/git clean/.test(error.message) || /Permission denied/.test(error.message))) { - ui.writeLine(error.message); - let msg = 'There was a permissions error during git file operations, ' + - 'please close any open project files/folders and try again.'; - return Promise.reject(new SilentError(msg.concat(branchErrMsg))); - } else { - return Promise.reject(error); - } - } - } -}); - - -export default githubPagesDeployCommand; diff --git a/packages/angular-cli/commands/help.ts b/packages/angular-cli/commands/help.ts deleted file mode 100644 index d0e02945402b..000000000000 --- a/packages/angular-cli/commands/help.ts +++ /dev/null @@ -1,70 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -const Command = require('../ember-cli/lib/models/command'); -const stringUtils = require('ember-cli-string-utils'); -const lookupCommand = require('../ember-cli/lib/cli/lookup-command'); - -const commandsToIgnore = [ - 'easter-egg', - 'destroy', - 'github-pages-deploy' // errors because there is no base github-pages command -]; - -const HelpCommand = Command.extend({ - name: 'help', - description: 'Shows help for the CLI', - works: 'everywhere', - - availableOptions: [], - - anonymousOptions: ['command-name (Default: all)'], - - run: function (commandOptions: any, rawArgs: any) { - let commandFiles = fs.readdirSync(__dirname) - // Remove files that are not JavaScript or Typescript - .filter(file => file.match(/\.(j|t)s$/) && !file.match(/\.d.ts$/)) - .map(file => path.parse(file).name) - .map(file => file.toLowerCase()); - - commandFiles = commandFiles.filter(file => { - return commandsToIgnore.indexOf(file) < 0; - }); - - let commandMap = commandFiles.reduce((acc: any, curr: string) => { - let classifiedName = stringUtils.classify(curr); - let defaultImport = require(`./${curr}`).default; - - acc[classifiedName] = defaultImport; - - return acc; - }, {}); - - if (rawArgs.indexOf('all') !== -1) { - rawArgs = []; // just act as if command not specified - } - - commandFiles.forEach(cmd => { - let Command = lookupCommand(commandMap, cmd); - - let command = new Command({ - ui: this.ui, - project: this.project, - commands: this.commands, - tasks: this.tasks - }); - - if (rawArgs.length > 0) { - if (cmd === rawArgs[0]) { - this.ui.writeLine(command.printBasicHelp(commandOptions)); - } - } else { - this.ui.writeLine(command.printBasicHelp(commandOptions)); - } - - }); - } -}); - -HelpCommand.overrideCore = true; -export default HelpCommand; diff --git a/packages/angular-cli/commands/init.ts b/packages/angular-cli/commands/init.ts deleted file mode 100644 index 2043549af8c4..000000000000 --- a/packages/angular-cli/commands/init.ts +++ /dev/null @@ -1,132 +0,0 @@ -import LinkCli from '../tasks/link-cli'; -import NpmInstall from '../tasks/npm-install'; - -const Command = require('../ember-cli/lib/models/command'); -const Promise = require('../ember-cli/lib/ext/promise'); -const SilentError = require('silent-error'); -const validProjectName = require('../ember-cli/lib/utilities/valid-project-name'); -const normalizeBlueprint = require('../ember-cli/lib/utilities/normalize-blueprint-option'); -const GitInit = require('../tasks/git-init'); - - -const InitCommand: any = Command.extend({ - name: 'init', - description: 'Creates a new angular-cli project in the current folder.', - aliases: ['i'], - works: 'everywhere', - - availableOptions: [ - { name: 'dry-run', type: Boolean, default: false, aliases: ['d'] }, - { name: 'verbose', type: Boolean, default: false, aliases: ['v'] }, - { name: 'link-cli', type: Boolean, default: false, aliases: ['lc'] }, - { name: 'skip-npm', type: Boolean, default: false, aliases: ['sn'] }, - { name: 'name', type: String, default: '', aliases: ['n'] }, - { name: 'source-dir', type: String, default: 'src', aliases: ['sd'] }, - { name: 'style', type: String, default: 'css' }, - { name: 'prefix', type: String, default: 'app', aliases: ['p'] }, - { name: 'mobile', type: Boolean, default: false }, - { name: 'routing', type: Boolean, default: false }, - { name: 'inline-style', type: Boolean, default: false, aliases: ['is'] }, - { name: 'inline-template', type: Boolean, default: false, aliases: ['it'] } - ], - - anonymousOptions: [''], - - run: function (commandOptions: any, rawArgs: string[]) { - if (commandOptions.dryRun) { - commandOptions.skipNpm = true; - } - - const installBlueprint = new this.tasks.InstallBlueprint({ - ui: this.ui, - project: this.project - }); - - // needs an explicit check in case it's just 'undefined' - // due to passing of options from 'new' and 'addon' - let gitInit: any; - if (commandOptions.skipGit === false) { - gitInit = new GitInit({ - ui: this.ui, - project: this.project - }); - } - - let npmInstall: any; - if (!commandOptions.skipNpm) { - npmInstall = new NpmInstall({ - ui: this.ui, - project: this.project - }); - } - - let linkCli: any; - if (commandOptions.linkCli) { - linkCli = new LinkCli({ - ui: this.ui, - project: this.project - }); - } - - const project = this.project; - const packageName = commandOptions.name !== '.' && commandOptions.name || project.name(); - - if (!packageName) { - const message = 'The `ng ' + this.name + '` command requires a ' + - 'package.json in current folder with name attribute or a specified name via arguments. ' + - 'For more details, use `ng help`.'; - - return Promise.reject(new SilentError(message)); - } - - const blueprintOpts = { - dryRun: commandOptions.dryRun, - blueprint: 'ng2', - rawName: packageName, - targetFiles: rawArgs || '', - rawArgs: rawArgs.toString(), - sourceDir: commandOptions.sourceDir, - style: commandOptions.style, - prefix: commandOptions.prefix, - mobile: commandOptions.mobile, - routing: commandOptions.routing, - inlineStyle: commandOptions.inlineStyle, - inlineTemplate: commandOptions.inlineTemplate, - ignoredUpdateFiles: ['favicon.ico'] - }; - - if (!validProjectName(packageName)) { - return Promise.reject( - new SilentError('We currently do not support a name of `' + packageName + '`.')); - } - - if (commandOptions.mobile) { - return Promise.reject(new SilentError( - 'The --mobile flag has been disabled temporarily while we await an update of ' + - 'angular-universal for supporting NgModule. Sorry for the inconvenience.' - )); - } - - blueprintOpts.blueprint = normalizeBlueprint(blueprintOpts.blueprint); - - return installBlueprint.run(blueprintOpts) - .then(function () { - if (commandOptions.skipGit === false) { - return gitInit.run(commandOptions, rawArgs); - } - }.bind(this)) - .then(function () { - if (!commandOptions.skipNpm) { - return npmInstall.run(); - } - }) - .then(function () { - if (commandOptions.linkCli) { - return linkCli.run(); - } - }); - } -}); - -InitCommand.overrideCore = true; -export default InitCommand; diff --git a/packages/angular-cli/commands/lint.ts b/packages/angular-cli/commands/lint.ts deleted file mode 100644 index 47857d62f15e..000000000000 --- a/packages/angular-cli/commands/lint.ts +++ /dev/null @@ -1,16 +0,0 @@ -const Command = require('../ember-cli/lib/models/command'); -import LintTask from '../tasks/lint'; - -export default Command.extend({ - name: 'lint', - description: 'Lints code in existing project', - works: 'insideProject', - run: function () { - const lintTask = new LintTask({ - ui: this.ui, - project: this.project - }); - - return lintTask.run(); - } -}); diff --git a/packages/angular-cli/commands/new.ts b/packages/angular-cli/commands/new.ts deleted file mode 100644 index 2a9258c9d7a2..000000000000 --- a/packages/angular-cli/commands/new.ts +++ /dev/null @@ -1,94 +0,0 @@ -import * as chalk from 'chalk'; -import InitCommand from './init'; -import {oneLine} from 'common-tags'; - -const Command = require('../ember-cli/lib/models/command'); -const Project = require('../ember-cli/lib/models/project'); -const SilentError = require('silent-error'); -const validProjectName = require('../ember-cli/lib/utilities/valid-project-name'); - - -const NewCommand = Command.extend({ - name: 'new', - description: `Creates a new directory and runs ${chalk.green('ng init')} in it.`, - works: 'outsideProject', - - availableOptions: [ - { name: 'dry-run', type: Boolean, default: false, aliases: ['d'] }, - { name: 'verbose', type: Boolean, default: false, aliases: ['v'] }, - { name: 'link-cli', type: Boolean, default: false, aliases: ['lc'] }, - { name: 'skip-npm', type: Boolean, default: false, aliases: ['sn'] }, - { name: 'skip-git', type: Boolean, default: false, aliases: ['sg'] }, - { name: 'directory', type: String, aliases: ['dir'] }, - { name: 'source-dir', type: String, default: 'src', aliases: ['sd'] }, - { name: 'style', type: String, default: 'css' }, - { name: 'prefix', type: String, default: 'app', aliases: ['p'] }, - { name: 'mobile', type: Boolean, default: false }, - { name: 'routing', type: Boolean, default: false }, - { name: 'inline-style', type: Boolean, default: false, aliases: ['is'] }, - { name: 'inline-template', type: Boolean, default: false, aliases: ['it'] } - ], - - run: function (commandOptions: any, rawArgs: string[]) { - const packageName = rawArgs.shift(); - - if (!packageName) { - return Promise.reject(new SilentError( - `The "ng ${this.name}" command requires a name argument to be specified. ` + - `For more details, use "ng help".`)); - } - if (!packageName.match(/^[a-zA-Z][.0-9a-zA-Z]*(-[a-zA-Z][.0-9a-zA-Z]*)*$/)) { - return Promise.reject(new SilentError(oneLine` - Project name "${packageName}" is not valid. New project names must - start with a letter, and must contain only alphanumeric characters or dashes. - `)); - } - - commandOptions.name = packageName; - if (commandOptions.dryRun) { - commandOptions.skipGit = true; - } - - if (packageName === '.') { - return Promise.reject(new SilentError( - `Trying to generate an application structure in this directory? Use "ng init" ` + - `instead.`)); - } - - if (!validProjectName(packageName)) { - return Promise.reject( - new SilentError(`We currently do not support a name of "${packageName}".`)); - } - - if (commandOptions.mobile) { - return Promise.reject(new SilentError( - 'The --mobile flag has been disabled temporarily while we await an update of ' + - 'angular-universal for supporting NgModule. Sorry for the inconvenience.' - )); - } - - if (!commandOptions.directory) { - commandOptions.directory = packageName; - } - - const createAndStepIntoDirectory = - new this.tasks.CreateAndStepIntoDirectory({ ui: this.ui }); - - const initCommand = new InitCommand({ - ui: this.ui, - tasks: this.tasks, - project: Project.nullProject(this.ui, this.cli) - }); - - return createAndStepIntoDirectory - .run({ - directoryName: commandOptions.directory, - dryRun: commandOptions.dryRun - }) - .then(initCommand.run.bind(initCommand, commandOptions, rawArgs)); - } -}); - - -NewCommand.overrideCore = true; -export default NewCommand; diff --git a/packages/angular-cli/commands/serve.ts b/packages/angular-cli/commands/serve.ts deleted file mode 100644 index 0f92707d96b2..000000000000 --- a/packages/angular-cli/commands/serve.ts +++ /dev/null @@ -1,183 +0,0 @@ -import * as denodeify from 'denodeify'; -const assign = require('lodash/assign'); -const SilentError = require('silent-error'); -const PortFinder = require('portfinder'); -const Command = require('../ember-cli/lib/models/command'); -import ServeWebpackTask from '../tasks/serve-webpack'; -import {Version} from '../upgrade/version'; - -PortFinder.basePort = 49152; - -const getPort = denodeify(PortFinder.getPort); -const defaultPort = process.env.PORT || 4200; - -export interface ServeTaskOptions { - port?: number; - host?: string; - proxyConfig?: string; - watcher?: string; - liveReload?: boolean; - liveReloadHost?: string; - liveReloadPort?: number; - liveReloadBaseUrl?: string; - liveReloadLiveCss?: boolean; - target?: string; - environment?: string; - ssl?: boolean; - sslKey?: string; - sslCert?: string; - aot?: boolean; - sourcemap?: boolean; - verbose?: boolean; - progress?: boolean; - open?: boolean; - vendorChunk?: boolean; - hmr?: boolean; - i18nFile?: string; - i18nFormat?: string; - locale?: string; -} - -const ServeCommand = Command.extend({ - name: 'serve', - description: 'Builds and serves your app, rebuilding on file changes.', - aliases: ['server', 's'], - - availableOptions: [ - { name: 'port', type: Number, default: defaultPort, aliases: ['p'] }, - { - name: 'host', - type: String, - default: 'localhost', - aliases: ['H'], - description: 'Listens only on localhost by default' - }, - { name: 'proxy-config', type: 'Path', aliases: ['pc'] }, - { name: 'watcher', type: String, default: 'events', aliases: ['w'] }, - { name: 'live-reload', type: Boolean, default: true, aliases: ['lr'] }, - { - name: 'live-reload-host', - type: String, - aliases: ['lrh'], - description: 'Defaults to host' - }, - { - name: 'live-reload-base-url', - type: String, - aliases: ['lrbu'], - description: 'Defaults to baseURL' - }, - { - name: 'live-reload-port', - type: Number, - aliases: ['lrp'], - description: '(Defaults to port number within [49152...65535])' - }, - { - name: 'live-reload-live-css', - type: Boolean, - default: true, - description: 'Whether to live reload CSS (default true)' - }, - { - name: 'target', - type: String, - default: 'development', - aliases: ['t', { 'dev': 'development' }, { 'prod': 'production' }] - }, - { name: 'environment', type: String, default: '', aliases: ['e'] }, - { name: 'ssl', type: Boolean, default: false }, - { name: 'ssl-key', type: String, default: 'ssl/server.key' }, - { name: 'ssl-cert', type: String, default: 'ssl/server.crt' }, - { name: 'aot', type: Boolean, default: false }, - { name: 'sourcemap', type: Boolean, default: true, aliases: ['sm'] }, - { name: 'vendor-chunk', type: Boolean, default: true }, - { name: 'verbose', type: Boolean, default: false }, - { name: 'progress', type: Boolean, default: true }, - { - name: 'open', - type: Boolean, - default: false, - aliases: ['o'], - description: 'Opens the url in default browser', - }, - { - name: 'hmr', - type: Boolean, - default: false, - description: 'Enable hot module replacement', - }, - { name: 'i18n-file', type: String, default: null }, - { name: 'i18n-format', type: String, default: null }, - { name: 'locale', type: String, default: null } - ], - - run: function(commandOptions: ServeTaskOptions) { - if (commandOptions.environment === '') { - if (commandOptions.target === 'development') { - commandOptions.environment = 'dev'; - } - if (commandOptions.target === 'production') { - commandOptions.environment = 'prod'; - } - } - - // Check angular version. - Version.assertAngularVersionIs2_3_1OrHigher(this.project.root); - commandOptions.liveReloadHost = commandOptions.liveReloadHost || commandOptions.host; - - return this._checkExpressPort(commandOptions) - .then(this._autoFindLiveReloadPort.bind(this)) - .then((opts: ServeTaskOptions) => { - commandOptions = assign({}, opts, { - baseURL: this.project.config(commandOptions.target).baseURL || '/' - }); - - const serve = new ServeWebpackTask({ - ui: this.ui, - project: this.project, - }); - - return serve.run(commandOptions); - }); - }, - - _checkExpressPort: function(commandOptions: ServeTaskOptions) { - return getPort({ port: commandOptions.port, host: commandOptions.host }) - .then((foundPort: number) => { - - if (commandOptions.port !== foundPort && commandOptions.port !== 0) { - throw new SilentError(`Port ${commandOptions.port} is already in use.`); - } - - // otherwise, our found port is good - commandOptions.port = foundPort; - return commandOptions; - - }); - }, - - _autoFindLiveReloadPort: function(commandOptions: ServeTaskOptions) { - return getPort({ port: commandOptions.liveReloadPort, host: commandOptions.liveReloadHost }) - .then((foundPort: number) => { - - // if live reload port matches express port, try one higher - if (foundPort === commandOptions.port) { - commandOptions.liveReloadPort = foundPort + 1; - return this._autoFindLiveReloadPort(commandOptions); - } - - // port was already open - if (foundPort === commandOptions.liveReloadPort) { - return commandOptions; - } - - // use found port as live reload port - commandOptions.liveReloadPort = foundPort; - return commandOptions; - - }); - } -}); - -export default ServeCommand; diff --git a/packages/angular-cli/commands/set.ts b/packages/angular-cli/commands/set.ts deleted file mode 100644 index 6530f7ee5908..000000000000 --- a/packages/angular-cli/commands/set.ts +++ /dev/null @@ -1,53 +0,0 @@ -const SilentError = require('silent-error'); -const Command = require('../ember-cli/lib/models/command'); -import {CliConfig} from '../models/config'; - - -const SetCommand = Command.extend({ - name: 'set', - description: 'Set a value in the configuration.', - works: 'everywhere', - - availableOptions: [ - { name: 'global', type: Boolean, default: false, aliases: ['g'] }, - ], - - asBoolean: function (raw: string): boolean { - if (raw == 'true' || raw == '1') { - return true; - } else if (raw == 'false' || raw == '' || raw == '0') { - return false; - } else { - throw new SilentError(`Invalid boolean value: "${raw}"`); - } - }, - asNumber: function (raw: string): number { - if (Number.isNaN(+raw)) { - throw new SilentError(`Invalid number value: "${raw}"`); - } - return +raw; - }, - - run: function (commandOptions: any, rawArgs: string[]): Promise { - return new Promise(resolve => { - const [jsonPath, rawValue] = rawArgs; - const config = CliConfig.fromProject(); - const type = config.typeOf(jsonPath); - let value: any = rawValue; - - switch (type) { - case 'boolean': value = this.asBoolean(rawValue); break; - case 'number': value = this.asNumber(rawValue); break; - case 'string': value = rawValue; break; - - default: value = JSON.parse(rawValue); - } - - config.set(jsonPath, value); - config.save(); - resolve(); - }); - } -}); - -export default SetCommand; diff --git a/packages/angular-cli/commands/test.ts b/packages/angular-cli/commands/test.ts deleted file mode 100644 index 4d7b2bd211ca..000000000000 --- a/packages/angular-cli/commands/test.ts +++ /dev/null @@ -1,54 +0,0 @@ -const TestCommand = require('../ember-cli/lib/commands/test'); -import TestTask from '../tasks/test'; -import {CliConfig} from '../models/config'; - -export interface TestOptions { - watch?: boolean; - codeCoverage?: boolean; - lint?: boolean; - singleRun?: boolean; - browsers?: string; - colors?: boolean; - log?: string; - port?: number; - reporters?: string; - build?: boolean; - sourcemap?: boolean; - progress?: boolean; -} - - -const NgCliTestCommand = TestCommand.extend({ - availableOptions: [ - { name: 'watch', type: Boolean, default: true, aliases: ['w'] }, - { name: 'code-coverage', type: Boolean, default: false, aliases: ['cc'] }, - { name: 'lint', type: Boolean, default: false, aliases: ['l'] }, - { name: 'single-run', type: Boolean, default: false, aliases: ['sr'] }, - { name: 'progress', type: Boolean, default: true}, - { name: 'browsers', type: String }, - { name: 'colors', type: Boolean }, - { name: 'log-level', type: String }, - { name: 'port', type: Number }, - { name: 'reporters', type: String }, - { name: 'build', type: Boolean, default: true }, - { name: 'sourcemap', type: Boolean, default: true, aliases: ['sm'] } - ], - - run: function(commandOptions: TestOptions) { - this.project.ngConfig = this.project.ngConfig || CliConfig.fromProject(); - - const testTask = new TestTask({ - ui: this.ui, - project: this.project - }); - - if (!commandOptions.watch) { - // if not watching ensure karma is doing a single run - commandOptions.singleRun = true; - } - return testTask.run(commandOptions); - } -}); - -NgCliTestCommand.overrideCore = true; -export default NgCliTestCommand; diff --git a/packages/angular-cli/commands/version.ts b/packages/angular-cli/commands/version.ts deleted file mode 100644 index c37dc3ea4e9f..000000000000 --- a/packages/angular-cli/commands/version.ts +++ /dev/null @@ -1,85 +0,0 @@ -const Command = require('../ember-cli/lib/models/command'); -import * as path from 'path'; -import * as child_process from 'child_process'; - -const VersionCommand = Command.extend({ - name: 'version', - description: 'outputs angular-cli version', - aliases: ['v', '--version', '-v'], - works: 'everywhere', - - availableOptions: [{ - name: 'verbose', - type: Boolean, 'default': false - }], - - run: function (options: any) { - let versions: any = process.versions; - const pkg = require(path.resolve(__dirname, '..', 'package.json')); - let projPkg: any; - try { - projPkg = require(path.resolve(this.project.root, 'package.json')); - } catch (exception) { - projPkg = undefined; - } - - versions.os = process.platform + ' ' + process.arch; - - const alwaysPrint = ['node', 'os']; - const roots = ['@angular/', '@ngtools/']; - - let ngCliVersion = pkg.version; - if (!__dirname.match(/node_modules/)) { - let gitBranch = '??'; - try { - const gitRefName = '' + child_process.execSync('git symbolic-ref HEAD', {cwd: __dirname}); - gitBranch = path.basename(gitRefName.replace('\n', '')); - } catch (e) { - } - - ngCliVersion = `local (v${pkg.version}, branch: ${gitBranch})`; - } - - if (projPkg) { - roots.forEach(root => { - versions = Object.assign(versions, this.getDependencyVersions(projPkg, root)); - }); - } - this.printVersion('angular-cli', ngCliVersion); - - for (const module of Object.keys(versions)) { - const isRoot = roots.some(root => module.startsWith(root)); - if (options.verbose || alwaysPrint.indexOf(module) > -1 || isRoot) { - this.printVersion(module, versions[module]); - } - } - }, - - getDependencyVersions: function(pkg: any, prefix: string): any { - const modules: any = {}; - - Object.keys(pkg.dependencies || {}) - .concat(Object.keys(pkg.devDependencies || {})) - .filter(depName => depName && depName.startsWith(prefix)) - .forEach(key => modules[key] = this.getVersion(key)); - - return modules; - }, - - getVersion: function(moduleName: string): string { - const modulePkg = require(path.resolve( - this.project.root, - 'node_modules', - moduleName, - 'package.json')); - return modulePkg.version; - }, - - printVersion: function (module: string, version: string) { - this.ui.writeLine(module + ': ' + version); - } -}); - - -VersionCommand.overrideCore = true; -export default VersionCommand; diff --git a/packages/angular-cli/custom-typings.d.ts b/packages/angular-cli/custom-typings.d.ts deleted file mode 100644 index c82e20002580..000000000000 --- a/packages/angular-cli/custom-typings.d.ts +++ /dev/null @@ -1,34 +0,0 @@ -interface IWebpackDevServerConfigurationOptions { - contentBase?: string; - hot?: boolean; - historyApiFallback?: {[key: string]: any} | boolean; - compress?: boolean; - proxy?: {[key: string]: string}; - staticOptions?: any; - quiet?: boolean; - noInfo?: boolean; - lazy?: boolean; - filename?: string; - watchOptions?: { - aggregateTimeout?: number; - poll?: number; - }; - publicPath?: string; - headers?: { [key: string]: string }; - stats?: { [key: string]: boolean }; - inline: boolean; - https?: boolean; - key?: string; - cert?: string; -} - -interface WebpackProgressPluginOutputOptions { - colors?: boolean; - chunks?: boolean; - modules?: boolean; - reasons?: boolean; - chunkModules?: boolean; -} - -declare var HtmlWebpackPlugin: any; -declare var LoaderOptionsPlugin: any; diff --git a/packages/angular-cli/ember-cli/LICENSE.md b/packages/angular-cli/ember-cli/LICENSE.md deleted file mode 100644 index 3a86b810c14a..000000000000 --- a/packages/angular-cli/ember-cli/LICENSE.md +++ /dev/null @@ -1,21 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2013-2016 Stefan Penner, Robert Jackson and ember-cli contributors - -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/packages/angular-cli/ember-cli/lib/cli/cli.js b/packages/angular-cli/ember-cli/lib/cli/cli.js deleted file mode 100644 index 1cb5f67e7483..000000000000 --- a/packages/angular-cli/ember-cli/lib/cli/cli.js +++ /dev/null @@ -1,166 +0,0 @@ -'use strict'; - -var lookupCommand = require('./lookup-command'); -var Promise = require('../ext/promise'); -var getOptionArgs = require('../utilities/get-option-args'); -var debug = require('debug')('ember-cli:cli'); -var debugTesting = require('debug')('ember-cli:testing'); -var PlatformChecker = require('../utilities/platform-checker'); -var InstallationChecker = require('../models/installation-checker'); - -function CLI(options) { - this.name = options.name; - this.ui = options.ui; - this.testing = options.testing; - this.disableDependencyChecker = options.disableDependencyChecker; - this.root = options.root; - this.npmPackage = options.npmPackage; - - debug('testing %o', !!this.testing); -} - -module.exports = CLI; - -CLI.prototype.run = function(environment) { - return Promise.hash(environment).then(function(environment) { - var args = environment.cliArgs.slice(); - - if (args[0] === '--help') { - if (args.length === 1) { - args[0] = 'help'; - } else { - args.shift(); - args.push('--help'); - } - } - - var commandName = args.shift(); - var commandArgs = args; - var helpOptions; - var update; - - var CurrentCommand = lookupCommand(environment.commands, commandName, commandArgs, { - project: environment.project, - ui: this.ui - }); - - var command = new CurrentCommand({ - ui: this.ui, - commands: environment.commands, - tasks: environment.tasks, - project: environment.project, - settings: environment.settings, - testing: this.testing, - cli: this - }); - - getOptionArgs('--verbose', commandArgs).forEach(function(arg) { - process.env['EMBER_VERBOSE_' + arg.toUpperCase()] = 'true'; - }); - - var platform = new PlatformChecker(process.version); - if (!platform.isValid && !this.testing) { - if (platform.isDeprecated) { - this.ui.writeDeprecateLine('Node ' + process.version + - ' is no longer supported by Ember CLI. Please update to a more recent version of Node'); - } - - if (platform.isUntested) { - this.ui.writeWarnLine('WARNING: Node ' + process.version + - ' has currently not been tested against Ember CLI and may result in unexpected behaviour.'); - } - } - - debug('command: %s', commandName); - - if (!this.testing) { - process.chdir(environment.project.root); - var skipInstallationCheck = commandArgs.indexOf('--skip-installation-check') !== -1; - if (environment.project.isEmberCLIProject() && !skipInstallationCheck) { - new InstallationChecker({ project: environment.project }).checkInstallations(); - } - } - - command.beforeRun(commandArgs); - - return Promise.resolve(update).then(function() { - debugTesting('cli: command.validateAndRun'); - return command.validateAndRun(commandArgs); - }).then(function(result) { - // if the help option was passed, call the help command - if (result === 'callHelp') { - helpOptions = { - environment: environment, - commandName: commandName, - commandArgs: commandArgs - }; - - return this.callHelp(helpOptions); - } - - return result; - }.bind(this)).then(function(exitCode) { - debugTesting('cli: command run complete. exitCode: ' + exitCode); - // TODO: fix this - // Possibly this issue: https://github.com/joyent/node/issues/8329 - // Wait to resolve promise when running on windows. - // This ensures that stdout is flushed so acceptance tests get full output - - return new Promise(function(resolve) { - if (process.platform === 'win32') { - setTimeout(resolve, 250, exitCode); - } else { - resolve(exitCode); - } - }); - }.bind(this)); - - }.bind(this)).catch(this.logError.bind(this)); -}; - -CLI.prototype.callHelp = function(options) { - var environment = options.environment; - var commandName = options.commandName; - var commandArgs = options.commandArgs; - var helpIndex = commandArgs.indexOf('--help'); - var hIndex = commandArgs.indexOf('-h'); - - var HelpCommand = lookupCommand(environment.commands, 'help', commandArgs, { - project: environment.project, - ui: this.ui - }); - - var help = new HelpCommand({ - ui: this.ui, - commands: environment.commands, - tasks: environment.tasks, - project: environment.project, - settings: environment.settings, - testing: this.testing - }); - - if (helpIndex > -1) { - commandArgs.splice(helpIndex,1); - } - - if (hIndex > -1) { - commandArgs.splice(hIndex,1); - } - - commandArgs.unshift(commandName); - - return help.validateAndRun(commandArgs); -}; - -CLI.prototype.logError = function(error) { - if (this.testing && error) { - console.error(error.message); - if (error.stack) { - console.error(error.stack); - } - throw error; - } - this.ui.errorLog.push(error); - this.ui.writeError(error); - return 1; -}; diff --git a/packages/angular-cli/ember-cli/lib/cli/index.js b/packages/angular-cli/ember-cli/lib/cli/index.js deleted file mode 100644 index 945ec74b0b65..000000000000 --- a/packages/angular-cli/ember-cli/lib/cli/index.js +++ /dev/null @@ -1,74 +0,0 @@ -'use strict'; -var path = require('path'); - -// Main entry point -var Project = require('../models/project'); -var commands = require('../commands'); -var tasks = require('../tasks'); -var CLI = require('./cli'); -var packageConfig = require('../../../package.json'); -var debug = require('debug')('ember-cli:cli/index'); -var merge = require('lodash/merge'); - -var version = packageConfig.version; -var name = packageConfig.name; -var trackingCode = packageConfig.trackingCode; - -function clientId() { - var ConfigStore = require('configstore'); - var configStore = new ConfigStore('ember-cli'); - var id = configStore.get('client-id'); - - if (id) { - return id; - } else { - id = require('uuid').v4().toString(); - configStore.set('client-id', id); - return id; - } -} - -// Options: Array cliArgs, Stream inputStream, Stream outputStream -module.exports = function(options) { - var UI = options.UI || require('../ui'); - var Yam = options.Yam || require('yam'); - - // TODO: one UI (lib/models/project.js also has one for now...) - var ui = new UI({ - inputStream: options.inputStream, - outputStream: options.outputStream, - errorStream: options.errorStream || process.stderr, - errorLog: options.errorLog || [], - ci: process.env.CI || /^(dumb|emacs)$/.test(process.env.TERM), - writeLevel: ~process.argv.indexOf('--silent') ? 'ERROR' : undefined - }); - - var config = new Yam('ember-cli', { - primary: Project.getProjectRoot() - }); - - var defaultUpdateCheckerOptions = { - checkForUpdates: false - }; - - var cli = new CLI({ - ui: ui, - testing: options.testing, - name: options.cli ? options.cli.name : 'ember', - disableDependencyChecker: options.disableDependencyChecker, - root: options.cli ? options.cli.root : path.resolve(__dirname, '..', '..'), - npmPackage: options.cli ? options.cli.npmPackage : 'ember-cli' - }); - - var project = Project.projectOrnullProject(ui, cli); - - var environment = { - tasks: tasks, - cliArgs: options.cliArgs, - commands: commands, - project: project, - settings: merge(defaultUpdateCheckerOptions, config.getAll()) - }; - - return cli.run(environment); -}; diff --git a/packages/angular-cli/ember-cli/lib/cli/lookup-command.js b/packages/angular-cli/ember-cli/lib/cli/lookup-command.js deleted file mode 100644 index fba6b9369a7f..000000000000 --- a/packages/angular-cli/ember-cli/lib/cli/lookup-command.js +++ /dev/null @@ -1,64 +0,0 @@ -'use strict'; - -var UnknownCommand = require('../commands/unknown'); - -module.exports = function(commands, commandName, commandArgs, optionHash) { - var options = optionHash || {}; - var project = options.project; - var ui = options.ui; - - function aliasMatches(alias) { - return alias === commandName; - } - - function findCommand(commands, commandName) { - for (var key in commands) { - var command = commands[key]; - - var name = command.prototype.name; - var aliases = command.prototype.aliases || []; - - if (name === commandName || aliases.some(aliasMatches)) { - return command; - } - } - } - - // Attempt to find command in ember-cli core commands - var command = findCommand(commands, commandName); - - var addonCommand; - // Attempt to find command within addons - if (project && project.eachAddonCommand) { - project.eachAddonCommand(function(addonName, commands) { - addonCommand = findCommand(commands, commandName); - return !addonCommand; - }); - } - - if (command && addonCommand) { - if (addonCommand.overrideCore) { - ui.writeWarnLine('An ember-addon has attempted to override the core command "' + - command.prototype.name + '". The addon command will be used as the overridding was explicit.'); - - return addonCommand; - } - - ui.writeWarnLine('An ember-addon has attempted to override the core command "' + - command.prototype.name + '". The core command will be used.'); - return command; - } - - if (command) { - return command; - } - - if (addonCommand) { - return addonCommand; - } - - // if we didn't find anything, return an "UnknownCommand" - return UnknownCommand.extend({ - name: commandName - }); -}; diff --git a/packages/angular-cli/ember-cli/lib/commands.js b/packages/angular-cli/ember-cli/lib/commands.js deleted file mode 100644 index 801330451d53..000000000000 --- a/packages/angular-cli/ember-cli/lib/commands.js +++ /dev/null @@ -1,2 +0,0 @@ - -module.exports = { 'Unknown': require('./commands/unknown') }; diff --git a/packages/angular-cli/ember-cli/lib/commands/generate.js b/packages/angular-cli/ember-cli/lib/commands/generate.js deleted file mode 100644 index 8c9f5bc02dcc..000000000000 --- a/packages/angular-cli/ember-cli/lib/commands/generate.js +++ /dev/null @@ -1,154 +0,0 @@ -'use strict'; - -var chalk = require('chalk'); -var Command = require('../models/command'); -var Promise = require('../ext/promise'); -var Blueprint = require('../models/blueprint'); -var mergeBlueprintOptions = require('../utilities/merge-blueprint-options'); -var merge = require('lodash/merge'); -var reject = require('lodash/reject'); -var EOL = require('os').EOL; -var SilentError = require('silent-error'); - -module.exports = Command.extend({ - name: 'generate', - description: 'Generates new code from blueprints.', - aliases: ['g'], - works: 'insideProject', - - availableOptions: [ - { name: 'dry-run', type: Boolean, default: false, aliases: ['d'] }, - { name: 'verbose', type: Boolean, default: false, aliases: ['v'] }, - { name: 'pod', type: Boolean, default: false, aliases: ['p'] }, - { name: 'classic', type: Boolean, default: false, aliases: ['c'] }, - { name: 'dummy', type: Boolean, default: false, aliases: ['dum', 'id'] }, - { name: 'in-repo-addon', type: String, default: null, aliases: ['in-repo', 'ir'] } - ], - - anonymousOptions: [ - '' - ], - - beforeRun: mergeBlueprintOptions, - - run: function(commandOptions, rawArgs) { - var blueprintName = rawArgs[0]; - - if (!blueprintName) { - return Promise.reject(new SilentError('The `ember generate` command requires a ' + - 'blueprint name to be specified. ' + - 'For more details, use `ember help`.')); - } - var Task = this.tasks.GenerateFromBlueprint; - var task = new Task({ - ui: this.ui, - project: this.project, - testing: this.testing, - settings: this.settings - }); - - var taskArgs = { - args: rawArgs - }; - - if (this.settings && this.settings.usePods && !commandOptions.classic) { - commandOptions.pod = !commandOptions.pod; - } - - var taskOptions = merge(taskArgs, commandOptions || {}); - - if (this.project.initializeAddons) { - this.project.initializeAddons(); - } - - return task.run(taskOptions); - }, - - printDetailedHelp: function(options) { - this.ui.writeLine(this.getAllBlueprints(options)); - }, - - addAdditionalJsonForHelp: function(json, options) { - json.availableBlueprints = this.getAllBlueprints(options); - }, - - getAllBlueprints: function(options) { - var lookupPaths = this.project.blueprintLookupPaths(); - var blueprintList = Blueprint.list({ paths: lookupPaths }); - - var output = ''; - - var singleBlueprintName; - if (options.rawArgs) { - singleBlueprintName = options.rawArgs[0]; - } - - if (!singleBlueprintName && !options.json) { - output += EOL + ' Available blueprints:' + EOL; - } - - var collectionsJson = []; - - blueprintList.forEach(function(collection) { - var result = this.getPackageBlueprints(collection, options, singleBlueprintName); - if (options.json) { - var collectionJson = {}; - collectionJson[collection.source] = result; - collectionsJson.push(collectionJson); - } else { - output += result; - } - }, this); - - if (singleBlueprintName && !output && !options.json) { - output = chalk.yellow('The \'' + singleBlueprintName + - '\' blueprint does not exist in this project.') + EOL; - } - - if (options.json) { - return collectionsJson; - } else { - return output; - } - }, - - getPackageBlueprints: function(collection, options, singleBlueprintName) { - var verbose = options.verbose; - var blueprints = collection.blueprints; - - if (!verbose) { - blueprints = reject(blueprints, 'overridden'); - } - - var output = ''; - - if (blueprints.length && !singleBlueprintName && !options.json) { - output += ' ' + collection.source + ':' + EOL; - } - - var blueprintsJson = []; - - blueprints.forEach(function(blueprint) { - var singleMatch = singleBlueprintName === blueprint.name; - if (singleMatch) { - verbose = true; - } - if (!singleBlueprintName || singleMatch) { - // this may add default keys for printing - blueprint.availableOptions.forEach(this.normalizeOption); - - if (options.json) { - blueprintsJson.push(blueprint.getJson(verbose)); - } else { - output += blueprint.printBasicHelp(verbose) + EOL; - } - } - }, this); - - if (options.json) { - return blueprintsJson; - } else { - return output; - } - } -}); diff --git a/packages/angular-cli/ember-cli/lib/commands/test.js b/packages/angular-cli/ember-cli/lib/commands/test.js deleted file mode 100644 index 170e9111d22c..000000000000 --- a/packages/angular-cli/ember-cli/lib/commands/test.js +++ /dev/null @@ -1,98 +0,0 @@ -'use strict'; - -var Command = require('../models/command'); -var SilentError = require('silent-error'); -var path = require('path'); -var win = require('../utilities/windows-admin'); -var existsSync = require('exists-sync'); - -var defaultPort = 7357; - -module.exports = Command.extend({ - name: 'test', - description: 'Runs your app\'s test suite.', - aliases: ['t'], - - availableOptions: [ - { name: 'environment', type: String, default: 'test', aliases: ['e'] }, - { name: 'config-file', type: String, aliases: ['c', 'cf']}, - { name: 'server', type: Boolean, default: false, aliases: ['s'] }, - { name: 'host', type: String, aliases: ['H'] }, - { name: 'test-port', type: Number, default: defaultPort, aliases: ['tp'], description: 'The test port to use when running with --server.' }, - { name: 'filter', type: String, aliases: ['f'], description: 'A string to filter tests to run' }, - { name: 'module', type: String, aliases: ['m'], description: 'The name of a test module to run' }, - // { name: 'watcher', type: String, default: 'events', aliases: ['w'] }, - { name: 'launch', type: String, default: false, description: 'A comma separated list of browsers to launch for tests.' }, - { name: 'reporter', type: String, aliases: ['r'], description: 'Test reporter to use [tap|dot|xunit] (default: tap)' }, - { name: 'silent', type: Boolean, default: false, description: 'Suppress any output except for the test report' }, - { name: 'test-page', type: String, description: 'Test page to invoke' }, - { name: 'path', type: 'Path', description: 'Reuse an existing build at given path.' }, - { name: 'query', type: String, description: 'A query string to append to the test page URL.' } - ], - - init: function() { - this.assign = require('lodash/assign'); - this.quickTemp = require('quick-temp'); - - if (!this.testing) { - process.env.EMBER_CLI_TEST_COMMAND = true; - } - }, - - tmp: function() { - return this.quickTemp.makeOrRemake(this, '-testsDist'); - }, - - rmTmp: function() { - this.quickTemp.remove(this, '-testsDist'); - this.quickTemp.remove(this, '-customConfigFile'); - }, - - _generateCustomConfigs: function(options) { - var config = {}; - if (!options.filter && !options.module && !options.launch && !options.query && !options['test-page']) { return config; } - - var testPage = options['test-page']; - var queryString = this.buildTestPageQueryString(options); - if (testPage) { - var containsQueryString = testPage.indexOf('?') > -1; - var testPageJoinChar = containsQueryString ? '&' : '?'; - config.testPage = testPage + testPageJoinChar + queryString; - } - if (queryString) { - config.queryString = queryString; - } - - if (options.launch) { - config.launch = options.launch; - } - - return config; - }, - - _generateTestPortNumber: function(options) { - if (options.port && options.testPort !== defaultPort || !isNaN(parseInt(options.testPort)) && !options.port) { return options.testPort; } - if (options.port) { return parseInt(options.port, 10) + 1; } - }, - - buildTestPageQueryString: function(options) { - var params = []; - - if (options.module) { - params.push('module=' + options.module); - } - - if (options.filter) { - params.push('filter=' + options.filter.toLowerCase()); - } - - if (options.query) { - params.push(options.query); - } - - return params.join('&'); - }, - - run: function(commandOptions) { - } -}); diff --git a/packages/angular-cli/ember-cli/lib/commands/unknown.js b/packages/angular-cli/ember-cli/lib/commands/unknown.js deleted file mode 100644 index 1ceb9aeff77d..000000000000 --- a/packages/angular-cli/ember-cli/lib/commands/unknown.js +++ /dev/null @@ -1,20 +0,0 @@ -'use strict'; - -var Command = require('../models/command'); -var SilentError = require('silent-error'); -var chalk = require('chalk'); - -module.exports = Command.extend({ - skipHelp: true, - unknown: true, - - printBasicHelp: function() { - return chalk.red('No help entry for \'' + this.name + '\''); - }, - - validateAndRun: function() { - throw new SilentError('The specified command ' + this.name + - ' is invalid. For available options, see' + - ' `ng help`.'); - } -}); diff --git a/packages/angular-cli/ember-cli/lib/ext/core-object.js b/packages/angular-cli/ember-cli/lib/ext/core-object.js deleted file mode 100644 index 0ac759b52263..000000000000 --- a/packages/angular-cli/ember-cli/lib/ext/core-object.js +++ /dev/null @@ -1,29 +0,0 @@ -'use strict'; - -function CoreObject(options) { - Object.assign(this, options); -} - -module.exports = CoreObject; - -CoreObject.prototype.constructor = CoreObject; - -CoreObject.extend = function(options) { - var constructor = this; - function Class() { - constructor.apply(this, arguments); - if (this.init) { - this.init(options); - } - } - - Class.__proto__ = CoreObject; - - Class.prototype = Object.create(constructor.prototype); - Object.assign(Class.prototype, options); - Class.prototype.constructor = Class; - Class.prototype._super = constructor.prototype; - - return Class; -}; - diff --git a/packages/angular-cli/ember-cli/lib/ext/promise.js b/packages/angular-cli/ember-cli/lib/ext/promise.js deleted file mode 100644 index c83af6a0dc7c..000000000000 --- a/packages/angular-cli/ember-cli/lib/ext/promise.js +++ /dev/null @@ -1,68 +0,0 @@ -'use strict'; - -var RSVP = require('rsvp'); -var Promise = RSVP.Promise; - -module.exports = PromiseExt; - -// Utility functions on the native CTOR need some massaging -module.exports.hash = function() { - return this.resolve(RSVP.hash.apply(null, arguments)); -}; - -module.exports.denodeify = function() { - var fn = RSVP.denodeify.apply(null, arguments); - var Constructor = this; - var newFn = function() { - return Constructor.resolve(fn.apply(null, arguments)); - }; - newFn.__proto__ = arguments[0]; - return newFn; -}; - -module.exports.filter = function() { - return this.resolve(RSVP.filter.apply(null, arguments)); -}; - -module.exports.map = function() { - return this.resolve(RSVP.map.apply(null, arguments)); -}; - -function PromiseExt(resolver, label) { - this._superConstructor(resolver, label); -} - -PromiseExt.prototype = Object.create(Promise.prototype); -PromiseExt.prototype.constructor = PromiseExt; -PromiseExt.prototype._superConstructor = Promise; -PromiseExt.__proto__ = Promise; - -PromiseExt.prototype.returns = function(value) { - return this.then(function() { - return value; - }); -}; - -PromiseExt.prototype.invoke = function(method) { - var args = Array.prototype.slice(arguments, 1); - - return this.then(function(value) { - return value[method].apply(value, args); - }, undefined, 'invoke: ' + method + ' with: ' + args); -}; - -function constructorMethod(promise, methodName, fn) { - var Constructor = promise.constructor; - - return promise.then(function(values) { - return Constructor[methodName](values, fn); - }); -} - -PromiseExt.prototype.map = function(mapFn) { - return constructorMethod(this, 'map', mapFn); -}; - -PromiseExt.prototype.filter = function(filterFn) { - return constructorMethod(this, 'filter', filterFn); -}; diff --git a/packages/angular-cli/ember-cli/lib/models/addon-discovery.js b/packages/angular-cli/ember-cli/lib/models/addon-discovery.js deleted file mode 100644 index 17656d2f0a7d..000000000000 --- a/packages/angular-cli/ember-cli/lib/models/addon-discovery.js +++ /dev/null @@ -1,260 +0,0 @@ -'use strict'; - -/** -@module ember-cli -*/ - -var assign = require('lodash/assign'); -var debug = require('debug')('ember-cli:addon-discovery'); -var existsSync = require('exists-sync'); -var path = require('path'); -var CoreObject = require('../ext/core-object'); -var resolve = require('resolve'); -var findup = require('findup'); - -/** - AddonDiscovery is responsible for collecting information about all of the - addons that will be used with a project. - - @class AddonDiscovery - @extends CoreObject - @constructor -*/ - -function AddonDiscovery(ui) { - this.ui = ui; -} - -AddonDiscovery.__proto__ = CoreObject; -AddonDiscovery.prototype.constructor = AddonDiscovery; - -/** - This is one of the primary APIs for this class and is called by the project. - It returns a tree of plain objects that each contain information about a - discovered addon. Each node has `name`, `path`, `pkg` and - `childAddons` properties. The latter is an array containing any addons - discovered from applying the discovery process to that addon. - - @private - @method discoverProjectAddons - */ -AddonDiscovery.prototype.discoverProjectAddons = function(project) { - var projectAsAddon = this.discoverFromProjectItself(project); - var internalAddons = this.discoverFromInternalProjectAddons(project); - var cliAddons = this.discoverFromCli(project.cli); - var dependencyAddons; - - if (project.hasDependencies()) { - dependencyAddons = this.discoverFromDependencies(project.root, project.nodeModulesPath, project.pkg, false); - } else { - dependencyAddons = []; - } - - var inRepoAddons = this.discoverInRepoAddons(project.root, project.pkg); - var addons = projectAsAddon.concat(cliAddons, internalAddons, dependencyAddons, inRepoAddons); - - return addons; -}; - -/** - This is one of the primary APIs for this class and is called by addons. - It returns a tree of plain objects that each contain information about a - discovered addon. Each node has `name`, `path`, `pkg` and - `childAddons` properties. The latter is an array containing any addons - discovered from applying the discovery process to that addon. - - @private - @method discoverProjectAddons - */ -AddonDiscovery.prototype.discoverChildAddons = function(addon) { - debug('discoverChildAddons: %s(%s)', addon.name, addon.root); - var dependencyAddons = this.discoverFromDependencies(addon.root, addon.nodeModulesPath, addon.pkg, true); - var inRepoAddons = this.discoverInRepoAddons(addon.root, addon.pkg); - var addons = dependencyAddons.concat(inRepoAddons); - - addons = addons.filter(function(childAddon) { - return !addon.shouldIncludeChildAddon || addon.shouldIncludeChildAddon(childAddon); - }); - - return addons; -}; - -/** - Returns an array containing zero or one nodes, depending on whether or not - the passed project is an addon. - - @private - @method discoverFromProjectItself - */ -AddonDiscovery.prototype.discoverFromProjectItself = function(project) { - if (project.isEmberCLIAddon()) { - var addonPkg = this.discoverAtPath(project.root); - if (addonPkg) { - return [addonPkg]; - } - } - return []; -}; - -/** - Returns a tree based on the addons referenced in the provided `pkg` through - the package.json `dependencies` and optionally `devDependencies` collections, - as well as those discovered addons' child addons. - - @private - @method discoverFromDependencies - */ -AddonDiscovery.prototype.discoverFromDependencies = function(root, nodeModulesPath, pkg, excludeDevDeps) { - var discovery = this; - var addons = Object.keys(this.dependencies(pkg, excludeDevDeps)).map(function(name) { - if (name !== 'ember-cli') { - var addonPath = this.resolvePackage(root, name); - - if (addonPath) { - return discovery.discoverAtPath(addonPath); - } - - // this supports packages that do not have a valid entry point - // script (aka `main` entry in `package.json` or `index.js`) - addonPath = path.join(nodeModulesPath, name); - var addon = discovery.discoverAtPath(addonPath); - if (addon) { - var chalk = require('chalk'); - - discovery.ui.writeLine(chalk.yellow('The package `' + name + '` is not a properly formatted package, we have used a fallback lookup to resolve it at `' + addonPath + '`. This is generally caused by an addon not having a `main` entry point (or `index.js`).'), 'WARNING'); - - return addon; - } - } - }, this).filter(Boolean); - return addons; -}; - -AddonDiscovery.prototype.resolvePackage = function(root, packageName) { - try { - - var entryModulePath = resolve.sync(packageName, { basedir: root }); - - return findup.sync(entryModulePath, 'package.json'); - } catch (e) { - var acceptableError = 'Cannot find module \'' + packageName + '\' from \'' + root + '\''; - // pending: https://github.com/substack/node-resolve/pull/80 - var workAroundError = 'Cannot read property \'isFile\' of undefined'; - - if (e.message === workAroundError || e.message === acceptableError) { - return; - } - throw e; - } -}; - -/** - Returns a tree based on the in-repo addons referenced in the provided `pkg` - through paths listed in the `ember-addon` entry, as well as those discovered - addons' child addons. - - @private - @method discoverInRepoAddons - */ -AddonDiscovery.prototype.discoverInRepoAddons = function(root, pkg) { - if (!pkg || !pkg['ember-addon'] || !pkg['ember-addon'].paths) { - return []; - } - var discovery = this; - var addons = pkg['ember-addon'].paths.map(function(addonPath) { - addonPath = path.join(root, addonPath); - return discovery.discoverAtPath(addonPath); - }, this).filter(Boolean); - return addons; -}; - -/** - Returns a tree based on the internal addons that may be defined within the project. - It does this by consulting the projects `supportedInternalAddonPaths()` method, which - is primarily used for middleware addons. - - @private - @method discoverFromInternalProjectAddons - */ -AddonDiscovery.prototype.discoverFromInternalProjectAddons = function(project) { - var discovery = this; - return project.supportedInternalAddonPaths().map(function(path) { - return discovery.discoverAtPath(path); - }).filter(Boolean); -}; - -AddonDiscovery.prototype.discoverFromCli = function (cli) { - if (!cli) { return []; } - - var cliPkg = require(path.resolve(cli.root, 'package.json')); - return this.discoverInRepoAddons(cli.root, cliPkg); -}; - -/** - Given a particular path, return undefined if the path is not an addon, or if it is, - a node with the info about the addon. - - @private - @method discoverAtPath - */ -AddonDiscovery.prototype.discoverAtPath = function(addonPath) { - var pkgPath = path.join(addonPath, 'package.json'); - debug('attemping to add: %s', addonPath); - - if (existsSync(pkgPath)) { - var addonPkg = require(pkgPath); - var keywords = addonPkg.keywords || []; - debug(' - module found: %s', addonPkg.name); - - addonPkg['ember-addon'] = addonPkg['ember-addon'] || {}; - - if (keywords.indexOf('ember-addon') > -1) { - debug(' - is addon, adding...'); - var addonInfo = { - name: addonPkg.name, - path: addonPath, - pkg: addonPkg, - }; - return addonInfo; - } else { - debug(' - no ember-addon keyword, not including.'); - } - } else { - debug(' - no package.json (looked at ' + pkgPath + ').'); - } - - return null; -}; - -/** - Returns the dependencies from a package.json - - @private - @method dependencies - @param {Object} pkg Package object. If false, the current package is used. - @param {Boolean} excludeDevDeps Whether or not development dependencies should be excluded, defaults to false. - @return {Object} Dependencies - */ -AddonDiscovery.prototype.dependencies = function(pkg, excludeDevDeps) { - pkg = pkg || {}; - - var devDependencies = pkg['devDependencies']; - if (excludeDevDeps) { - devDependencies = {}; - } - - return assign({}, devDependencies, pkg['dependencies']); -}; - -AddonDiscovery.prototype.addonPackages = function(addonsList) { - var addonPackages = {}; - - addonsList.forEach(function(addonPkg) { - addonPackages[addonPkg.name] = addonPkg; - }); - - return addonPackages; -}; - -// Export -module.exports = AddonDiscovery; diff --git a/packages/angular-cli/ember-cli/lib/models/addon.js b/packages/angular-cli/ember-cli/lib/models/addon.js deleted file mode 100644 index f0dc2b504f3a..000000000000 --- a/packages/angular-cli/ember-cli/lib/models/addon.js +++ /dev/null @@ -1,948 +0,0 @@ -'use strict'; - -/** -@module ember-cli -*/ - -var existsSync = require('exists-sync'); -var path = require('path'); -var assign = require('lodash/assign'); -var SilentError = require('silent-error'); -var debug = require('debug')('ember-cli:addon'); - -var nodeModulesPath = require('node-modules-path'); - -var p = require('ember-cli-preprocess-registry/preprocessors'); -var preprocessJs = p.preprocessJs; -var preprocessCss = p.preprocessCss; -var preprocessTemplates = p.preprocessTemplates; - -var AddonDiscovery = require('../models/addon-discovery'); -var AddonsFactory = require('../models/addons-factory'); -var CoreObject = require('../ext/core-object'); -var Project = require('./project'); - -var walkSync = require('walk-sync'); - - -function mergeTrees(inputTree, options) { - options = options || {}; - options.description = options.annotation; - var tree = upstreamMergeTrees(inputTree, options); - - tree.description = options && options.description; - - return tree; -} - - -/** - Root class for an Addon. If your addon module exports an Object this - will be extended from this base class. If you export a constructor (function), - it will **not** extend from this class. - - Hooks: - - - {{#crossLink "Addon/config:method"}}config{{/crossLink}} - - {{#crossLink "Addon/blueprintsPath:method"}}blueprintsPath{{/crossLink}} - - {{#crossLink "Addon/includedCommands:method"}}includedCommands{{/crossLink}} - - {{#crossLink "Addon/serverMiddleware:method"}}serverMiddleware{{/crossLink}} - - {{#crossLink "Addon/postBuild:method"}}postBuild{{/crossLink}} - - {{#crossLink "Addon/outputReady:method"}}outputReady{{/crossLink}} - - {{#crossLink "Addon/preBuild:method"}}preBuild{{/crossLink}} - - {{#crossLink "Addon/buildError:method"}}buildError{{/crossLink}} - - {{#crossLink "Addon/included:method"}}included{{/crossLink}} - - {{#crossLink "Addon/postprocessTree:method"}}postprocessTree{{/crossLink}} - - {{#crossLink "Addon/treeFor:method"}}treeFor{{/crossLink}} - - @class Addon - @extends CoreObject - @constructor - @param {(Project|Addon)} parent The project or addon that directly depends on this addon - @param {Project} project The current project (deprecated) -*/ -function Addon(parent, project) { - this.parent = parent; - this.project = project; - this.ui = project && project.ui; - this.addonPackages = {}; - this.addons = []; - this.addonDiscovery = new AddonDiscovery(this.ui); - this.addonsFactory = new AddonsFactory(this, this.project); - this.registry = p.defaultRegistry(this); - this._didRequiredBuildPackages = false; - - if (!this.root) { - throw new Error('Addon classes must be instantiated with the `root` property'); - } - this.nodeModulesPath = nodeModulesPath(this.root); - - p.setupRegistry(this); - - if (!this.name) { - throw new SilentError('An addon must define a `name` property.'); - } -} - -Addon.__proto__ = CoreObject; -Addon.prototype.constructor = Addon; - -Addon.prototype.hintingEnabled = function() { - var isProduction = process.env.EMBER_ENV === 'production'; - var testsEnabledDefault = process.env.EMBER_CLI_TEST_COMMAND || !isProduction; - - return testsEnabledDefault; -}; - -/** - Loads all required modules for a build - - @private - @method _requireBuildPackages - */ - -Addon.prototype._requireBuildPackages = function() { - if (this._didRequiredBuildPackages === true) { - return; - } else { - this._didRequiredBuildPackages = true; - } - - this.transpileModules = deprecatedAddonFilters(this, 'this.transpileModules', 'broccoli-es6modules', function(tree, options) { - }); - - this.pickFiles = deprecatedAddonFilters(this, 'this.pickFiles', 'broccoli-funnel', function(tree, options) { - return new Funnel(tree, options); - }); - - - this.Funnel = deprecatedAddonFilters(this, 'new this.Funnel(..)', 'broccoli-funnel', function(tree, options) { - return new Funnel(tree, options); - }); - - this.walkSync = deprecatedAddonFilters(this, 'this.walkSync', 'node-walk-sync', walkSync); -}; - -function deprecatedAddonFilters(addon, name, insteadUse, fn) { - return function(tree, options) { - var message = name + ' is deprecated, please use ' + - insteadUse + ' directly instead [addon: ' + addon.name + ']'; - - this.ui && this.ui.writeDeprecateLine(message); - - if (!this.ui) { - console.warn(message); - } - - return fn(tree, options); - }; -} - -/** - Shorthand method for [broccoli-concat](https://github.com/ember-cli/broccoli-concat) - - @private - @method concatFiles - @param {tree} tree Tree of files - @param {Object} options Options for broccoli-concat - @return {tree} Modified tree -*/ -Addon.prototype.concatFiles = function(tree, options) { -}; - -/** - Returns whether or not this addon is running in development - - @public - @method isDevelopingAddon - @return {Boolean} -*/ -Addon.prototype.isDevelopingAddon = function() { - if (process.env.EMBER_ADDON_ENV === 'development' && (this.parent instanceof Project)) { - return this.parent.name() === this.name; - } - return false; -}; - -/** - Discovers all child addons of this addon and stores their names and - package.json contents in this.addonPackages as key-value pairs - - @private - @method discoverAddons - */ -Addon.prototype.discoverAddons = function() { - var addonsList = this.addonDiscovery.discoverChildAddons(this); - - this.addonPackages = this.addonDiscovery.addonPackages(addonsList); -}; - -Addon.prototype.initializeAddons = function() { - if (this._addonsInitialized) { - return; - } - this._addonsInitialized = true; - - debug('initializeAddons for: %s', this.name); - - this.discoverAddons(); - this.addons = this.addonsFactory.initializeAddons(this.addonPackages); - - this.addons.forEach(function(addon) { - debug('addon: %s', addon.name); - }); -}; - -/** - Invoke the specified method for each enabled addon. - - @private - @method eachAddonInvoke - @param {String} methodName the method to invoke on each addon - @param {Array} args the arguments to pass to the invoked method -*/ -Addon.prototype.eachAddonInvoke = function eachAddonInvoke(methodName, args) { - this.initializeAddons(); - - var invokeArguments = args || []; - - return this.addons.map(function(addon) { - if (addon[methodName]) { - return addon[methodName].apply(addon, invokeArguments); - } - }).filter(Boolean); -}; - -/** - Returns a given type of tree (if present), merged with the - application tree. For each of the trees available using this - method, you can also use a direct method called treeFor[Type] (eg. `treeForApp`). - - Available tree names: - - {{#crossLink "Addon/treeForApp:method"}}app{{/crossLink}} - - {{#crossLink "Addon/treeForStyles:method"}}styles{{/crossLink}} - - {{#crossLink "Addon/treeForTemplates:method"}}templates{{/crossLink}} - - {{#crossLink "Addon/treeForAddonTemplates:method"}}addon-templates{{/crossLink}} - - {{#crossLink "Addon/treeForAddon:method"}}addon{{/crossLink}} - - {{#crossLink "Addon/treeForVendor:method"}}vendor{{/crossLink}} - - {{#crossLink "Addon/treeForTestSupport:method"}}test-support{{/crossLink}} - - {{#crossLink "Addon/treeForPublic:method"}}public{{/crossLink}} - - @public - @method treeFor - @param {String} name - @return {tree} -*/ -Addon.prototype.treeFor = function treeFor(name) { - this._requireBuildPackages(); - - var trees = this.eachAddonInvoke('treeFor', [name]); - var tree = this._treeFor(name); - - if (tree) { - trees.push(tree); - } - - if (this.isDevelopingAddon() && this.hintingEnabled() && name === 'app') { - trees.push(this.jshintAddonTree()); - } - - // return mergeTrees(trees.filter(Boolean), { - // overwrite: true, - // annotation: 'Addon#treeFor (' + this.name + ' - ' + name + ')' - // }); -}; -// -// /** -// @private -// @param {String} name -// @method _treeFor -// @return {tree} -// */ -// Addon.prototype._treeFor = function _treeFor(name) { -// var treePath = path.resolve(this.root, this.treePaths[name]); -// var treeForMethod = this.treeForMethods[name]; -// var tree; -// -// // if (existsSync(treePath)) { -// // tree = this.treeGenerator(treePath); -// // } -// -// if (this[treeForMethod]) { -// tree = this[treeForMethod](tree); -// } -// -// return tree; -// }; - -/** - This method is called when the addon is included in a build. You - would typically use this hook to perform additional imports - - ```js - included: function(app) { - app.import(somePath); - } - ``` - - @public - @method included - @param {EmberApp} app The application object -*/ -Addon.prototype.included = function(/* app */) { - if (!this._addonsInitialized) { - // someone called `this._super.included` without `apply` (because of older - // core-object issues that prevent a "real" super call from working properly) - return; - } - - this.eachAddonInvoke('included', [this]); -}; - -/** - Returns the tree for all app files - - @public - @method treeForApp - @param {Tree} tree - @return {Tree} App file tree -*/ -Addon.prototype.treeForApp = function(tree) { - return tree; -}; - -/** - Returns the tree for all template files - - @public - @method treeForTemplates - @param {Tree} tree - @return {Tree} Template file tree -*/ -Addon.prototype.treeForTemplates = function(tree) { - return tree; -}; - -/** - Returns the tree for this addon's templates - - @public - @method treeForAddonTemplates - @param {Tree} tree - @return {Tree} Addon Template file tree -*/ -Addon.prototype.treeForAddonTemplates = function(tree) { - return tree; -}; - -/** - Returns a tree for this addon - - @public - @method treeForAddon - @param {Tree} tree - @return {Tree} Addon file tree -*/ -Addon.prototype.treeForAddon = function(tree) { - this._requireBuildPackages(); - - if (!tree) { - return tree; - } - - var addonTree = this.compileAddon(tree); - var stylesTree = this.compileStyles(this._treeFor('addon-styles')); - - return mergeTrees([addonTree, stylesTree].filter(Boolean), { - annotation: 'Addon#treeForAddon(' + this.name + ')' - }); -}; - -/** - Returns the tree for all style files - - @public - @method treeForStyles - @param {Tree} tree The tree to process, usually `app/styles/` in the addon. - @returns {Tree} The return tree has the same contents as the input tree, but is moved so that the `app/styles/` path is preserved. -*/ -Addon.prototype.treeForStyles = function(tree) { - this._requireBuildPackages(); - - if (!tree) { - return tree; - } - - return new Funnel(tree, { - destDir: 'app/styles' - }); -}; - -/** - Returns the tree for all vendor files - - @public - @method treeForVendor - @param {Tree} tree - @return {Tree} Vendor file tree -*/ -Addon.prototype.treeForVendor = function(tree) { - return tree; -}; - -/** - Returns the tree for all test support files - - @public - @method treeForTestSupport - @param {Tree} tree - @return {Tree} Test Support file tree -*/ -Addon.prototype.treeForTestSupport = function(tree) { - return tree; -}; - -/** - Returns the tree for all public files - - @public - @method treeForPublic - @param {Tree} tree - @return {Tree} Public file tree -*/ -Addon.prototype.treeForPublic = function(tree) { - this._requireBuildPackages(); - - if (!tree) { - return tree; - } - - return new Funnel(tree, { - srcDir: '/', - destDir: '/' + this.moduleName() - }); -}; - -/** - Returns the tree for all test files namespaced to a given addon. - - @public - @method treeForAddonTestSupport - @param {Tree} tree - @return {Tree} - */ -Addon.prototype.treeForAddonTestSupport = function(tree) { - if (!tree) { - return tree; - } - - var processed = this.preprocessJs(tree, '/', this.name, { - registry: this.registry - }); - - return new Funnel(processed, { - srcDir: '/', - destDir: '/' + this.moduleName() + '/test-support' - }); -}; - -/** - Runs the styles tree through preprocessors. - - @private - @method compileStyles - @param {Tree} tree Styles file tree - @return {Tree} Compiled styles tree -*/ -Addon.prototype.compileStyles = function(tree) { - this._requireBuildPackages(); - - if (tree) { - return preprocessCss(tree, '/', '/', { - outputPaths: { 'addon': this.name + '.css' }, - registry: this.registry - }); - } -}; - -/** - Looks in the addon/ and addon/templates trees to determine if template files - exists that need to be precompiled. - - This is executed once when building, but not on rebuilds. - - @private - @method shouldCompileTemplates - @returns Boolean indicates if templates need to be compiled for this addon -*/ -Addon.prototype.shouldCompileTemplates = function() { - var templateExtensions = this.registry.extensionsForType('template'); - var addonTreePath = path.join(this.root, this.treePaths['addon']); - var addonTemplatesTreePath = path.join(this.root, this.treePaths['addon-templates']); - - var files = []; - - if (existsSync(addonTreePath)) { - files = files.concat(walkSync(addonTreePath)); - } - - if (existsSync(addonTemplatesTreePath)) { - files = files.concat(walkSync(addonTemplatesTreePath)); - } - - var extensionMatcher = new RegExp('(' + templateExtensions.join('|') + ')$'); - - return files.some(function(file) { - return file.match(extensionMatcher); - }); -}; - -Addon.prototype._addonTemplateFiles = function addonTemplateFiles(tree) { - this._requireBuildPackages(); - - if (this._cachedAddonTemplateFiles) { - return this._cachedAddonTemplateFiles; - } - - var trees = []; - var addonTemplates = this._treeFor('addon-templates'); - var standardTemplates; - - if (addonTemplates) { - standardTemplates = new Funnel(addonTemplates, { - srcDir: '/', - destDir: 'modules/' + this.name + '/templates' - }); - - trees.push(standardTemplates); - } - - var includePatterns = this.registry.extensionsForType('template').map(function(extension) { - return '**/*/template.' + extension; - }); - - var podTemplates = new Funnel(tree, { - include: includePatterns, - destDir: 'modules/' + this.name + '/', - annotation: 'Funnel: Addon Pod Templates' - }); - - trees.push(podTemplates); - - this._cachedAddonTemplateFiles = mergeTrees(trees, { - annotation: 'TreeMerge (' + this.name + ' templates)' - }); - - return this._cachedAddonTemplateFiles; -}; - -/** - Invoke the specified method for each of the project's addons. - - @private - @method _eachProjectAddonInvoke - @param {String} methodName the method to invoke on each addon - @param {Array} args the arguments to pass to the invoked method -*/ -Addon.prototype._eachProjectAddonInvoke = function eachProjectAddonInvoke(methodName, args) { - this.initializeAddons(); - - var invokeArguments = args || []; - - return this.project.addons.map(function(addon) { - if (addon[methodName]) { - return addon[methodName].apply(addon, invokeArguments); - } - }).filter(Boolean); -}; - -/** - Runs the templates tree through preprocessors. - - @private - @method compileTemplates - @param {Tree} tree Templates file tree - @return {Tree} Compiled templates tree -*/ -Addon.prototype.compileTemplates = function(tree) { - this._requireBuildPackages(); - - if (this.shouldCompileTemplates()) { - var plugins = this.registry.load('template'); - - if (plugins.length === 0) { - throw new SilentError('Addon templates were detected, but there ' + - 'are no template compilers registered for `' + this.name + '`. ' + - 'Please make sure your template precompiler (commonly `ember-cli-htmlbars`) ' + - 'is listed in `dependencies` (NOT `devDependencies`) in ' + - '`' + this.name + '`\'s `package.json`.'); - } - - - return preprocessTemplates(this._addonTemplateFiles(tree), { - annotation: 'compileTemplates(' + this.name + ')', - registry: this.registry - }); - } -}; - -/** - Runs the addon tree through preprocessors. - - @private - @method compileAddon - @param {Tree} tree Addon file tree - @return {Tree} Compiled addon tree -*/ -Addon.prototype.compileAddon = function(tree) { - this._requireBuildPackages(); - - var reexported; - - var addonJs = this.processedAddonJsFiles(tree); - var templatesTree = this.compileTemplates(tree); - - var trees = [addonJs, templatesTree, reexported].filter(Boolean); - - return mergeTrees(trees, { - annotation: 'Addon#compileAddon(' + this.name + ') ' - }); -}; - -/** - Returns a tree with JSHhint output for all addon JS. - - @private - @method jshintAddonTree - @return {Tree} Tree with JShint output (tests) -*/ -Addon.prototype.jshintAddonTree = function() { - this._requireBuildPackages(); - - var addonPath = path.join(this.root, this.treePaths['addon']); - - if (!existsSync(addonPath)) { - return; - } - - var addonJs = this.addonJsFiles(addonPath); - var addonTemplates = this._addonTemplateFiles(addonPath); - var lintJsTrees = this._eachProjectAddonInvoke('lintTree', ['addon', addonJs]); - var lintTemplateTrees = this._eachProjectAddonInvoke('lintTree', ['templates', addonTemplates]); - var lintTrees = [].concat(lintJsTrees, lintTemplateTrees).filter(Boolean); - var lintedAddon = mergeTrees(lintTrees, { - overwrite: true, - annotation: 'TreeMerger (addon-lint)' - }); - - return new Funnel(lintedAddon, { - srcDir: '/', - destDir: this.name + '/tests/' - }); -}; - -/** - Returns a tree containing the addon's js files - - @private - @method addonJsFiles - @return {Tree} The filtered addon js files -*/ -Addon.prototype.addonJsFiles = function(tree) { - this._requireBuildPackages(); - - if (this._cachedAddonJsFiles) { - return this._cachedAddonJsFiles; - } - - var includePatterns = this.registry.extensionsForType('js').map(function(extension) { - return new RegExp(extension + '$'); - }); - - this._cachedAddonJsFiles = new Funnel(tree, { - include: includePatterns, - destDir: 'modules/' + this.moduleName(), - description: 'Funnel: Addon JS' - }); - - return this._cachedAddonJsFiles; -}; - - -/** - Preprocesses a javascript tree. - - @private - @method preprocessJs - @return {Tree} Preprocessed javascript -*/ -Addon.prototype.preprocessJs = function() { - return preprocessJs.apply(preprocessJs, arguments); -}; - -/** - Returns a tree with all javascript for this addon. - - @private - @method processedAddonJsFiles - @param {Tree} the tree to preprocess - @return {Tree} Processed javascript file tree -*/ -Addon.prototype.processedAddonJsFiles = function(tree) { - return this.preprocessJs(this.addonJsFiles(tree), '/', this.name, { - registry: this.registry - }); -}; - -/** - Returns the module name for this addon. - - @public - @method moduleName - @return {String} module name -*/ -Addon.prototype.moduleName = function() { - if (!this.modulePrefix) { - this.modulePrefix = (this.modulePrefix || this.name).toLowerCase().replace(/\s/g, '-'); - } - - return this.modulePrefix; -}; - -/** - Returns the path for addon blueprints. - - @private - @method blueprintsPath - @return {String} The path for blueprints -*/ -Addon.prototype.blueprintsPath = function() { - var blueprintPath = path.join(this.root, 'blueprints'); - - if (existsSync(blueprintPath)) { - return blueprintPath; - } -}; - -/** - Augments the applications configuration settings. - Object returned from this hook is merged with the application's configuration object. - Application's configuration always take precedence. - - - ```js - config: function(environment, appConfig) { - return { - someAddonDefault: "foo" - }; - } - ``` - - @public - @method config - @param {String} env Name of current environment (ie "developement") - @param {Object} baseConfig Initial application configuration - @return {Object} Configuration object to be merged with application configuration. -*/ -Addon.prototype.config = function (env, baseConfig) { - var configPath = path.join(this.root, 'config', 'environment.js'); - - if (existsSync(configPath)) { - var configGenerator = require(configPath); - - return configGenerator(env, baseConfig); - } -}; - -/** - @public - @method dependencies - @return {Object} The addon's dependencies based on the addon's package.json -*/ -Addon.prototype.dependencies = function() { - var pkg = this.pkg || {}; - return assign({}, pkg['devDependencies'], pkg['dependencies']); -}; - -/** - @public - @method isEnabled - @return {Boolean} Whether or not this addon is enabled -*/ -Addon.prototype.isEnabled = function() { - return true; -}; - -/** - @public - @method shouldIncludeChildAddon - @return {Boolean} Whether or not a child addon is supposed to be included -*/ -Addon.prototype.shouldIncludeChildAddon = function() { - return true; -}; - -/** - Returns the absolute path for a given addon - - @private - @method resolvePath - @param {String} addon Addon name - @return {String} Absolute addon path -*/ -Addon.resolvePath = function(addon) { - var addonMain = addon.pkg['ember-addon-main']; - - if (addonMain) { - this.ui && this.ui.writeDeprecateLine(addon.pkg.name + ' is using the deprecated ember-addon-main definition. It should be updated to {\'ember-addon\': {\'main\': \'' + addon.pkg['ember-addon-main'] + '\'}}'); - } else { - addonMain = (addon.pkg['ember-addon'] && addon.pkg['ember-addon'].main) || addon.pkg['main'] || 'index.js'; - } - - // Resolve will fail unless it has an extension - if (!path.extname(addonMain)) { - addonMain += '.js'; - } - - return path.resolve(addon.path, addonMain); -}; - -/** - Returns the addon class for a given addon name. - If the Addon exports a function, that function is used - as constructor. If an Object is exported, a subclass of - `Addon` is returned with the exported hash merged into it. - - @private - @static - @method lookup - @param {String} addon Addon name - @return {Addon} Addon class -*/ -Addon.lookup = function(addon) { - var Constructor, addonModule, modulePath, moduleDir; - - modulePath = Addon.resolvePath(addon); - moduleDir = path.dirname(modulePath); - - if (existsSync(modulePath)) { - addonModule = require(modulePath); - - if (typeof addonModule === 'function') { - Constructor = addonModule; - Constructor.prototype.root = Constructor.prototype.root || moduleDir; - Constructor.prototype.pkg = Constructor.prototype.pkg || addon.pkg; - } else { - Constructor = Addon.extend(assign({ - root: moduleDir, - pkg: addon.pkg - }, addonModule)); - } - } - - if (!Constructor) { - throw new SilentError('The `' + addon.pkg.name + '` addon could not be found at `' + addon.path + '`.'); - } - - return Constructor; -}; - - -// Methods without default implementation - -/** - CLI commands included with this addon. This function should return - an object with command names and command instances/create options. - - This function is not implemented by default - - ```js - includedCommands: function() { - return { - 'do-foo': require('./lib/commands/foo') - }; - } - ``` - - @public - @method includedCommands - @return {Object} An object with included commands -*/ - - -/** - Post-process a tree - - @public - @method postProcessTree - @param {String} type What kind of tree (eg. 'javascript', 'styles') - @param {Tree} Tree to process - @return {Tree} Processed tree -*/ - -/** - This hook allows you to make changes to the express server run by ember-cli. - It's passed an `startOptions` object which contains: - - `app` Express server instance - - `options` A hash with: - - `project` Current {{#crossLink "Project"}}project{{/crossLink}} - - `watcher` - - `environment` - - This function is not implemented by default - - ```js - serverMiddleware: function(startOptions) { - var app = startOptions.app; - - app.use(function(req, res, next) { - // Some middleware - }); - } - ``` - - @public - @method serverMiddleware - @param {Object} startOptions Express server start options -*/ - -/** - This hook is called before a build takes place. - - @public - @method preBuild - @param {Object} result Build object -*/ - -/** - This hook is called after a build is complete. - - It's passed an `result` object which contains: - - `directory` Path to build output - - @public - @method postBuild - @param {Object} result Build result object -*/ - -/** - This hook is called after the build files have been copied to the output directory - - It's passed an `result` object which contains: - - `directory` Path to build output - - @public - @method outputReady - @param {Object} result Build result object -*/ - -/** - This hook is called when an error occurs during the preBuild, postBuild or outputReady hooks - for addons, or when the build fails - - @public - @method buildError - @param {Error} error The error that was caught during the processes listed above -*/ - -module.exports = Addon; diff --git a/packages/angular-cli/ember-cli/lib/models/addons-factory.js b/packages/angular-cli/ember-cli/lib/models/addons-factory.js deleted file mode 100644 index 39c231b43633..000000000000 --- a/packages/angular-cli/ember-cli/lib/models/addons-factory.js +++ /dev/null @@ -1,66 +0,0 @@ -'use strict'; - -/** -@module ember-cli -*/ - -var CoreObject = require('../ext/core-object'); -var DAG = require('../utilities/DAG'); -var debug = require('debug')('ember-cli:addons-factory'); - -/** - AddonsFactory is responsible for instantiating a collection of addons, in the right order. - - @class AddonsFactory - @extends CoreObject - @constructor -*/ -function AddonsFactory(addonParent, project) { - this.addonParent = addonParent; - this.project = project; -} - -AddonsFactory.__proto__ = CoreObject; -AddonsFactory.prototype.constructor = AddonsFactory; - -AddonsFactory.prototype.initializeAddons = function(addonPackages) { - var addonParent = this.addonParent; - var project = this.project; - var graph = new DAG(); - var Addon = require('../models/addon'); - var addonInfo, emberAddonConfig; - - debug('initializeAddons for: ', typeof addonParent.name === 'function' ? addonParent.name() : addonParent.name); - debug(' addon names are:', Object.keys(addonPackages)); - - for (var name in addonPackages) { - addonInfo = addonPackages[name]; - emberAddonConfig = addonInfo.pkg['ember-addon']; - - graph.addEdges(name, addonInfo, emberAddonConfig.before, emberAddonConfig.after); - } - - var addons = []; - graph.topsort(function (vertex) { - var addonInfo = vertex.value; - if (addonInfo) { - var AddonConstructor = Addon.lookup(addonInfo); - var addon = new AddonConstructor(addonParent, project); - if (addon.initializeAddons) { - addon.initializeAddons(); - } else { - addon.addons = []; - } - addons.push(addon); - } - }); - - debug(' addons ordered as:', addons.map(function(addon) { - return addon.name; - })); - - return addons; -}; - -// Export -module.exports = AddonsFactory; diff --git a/packages/angular-cli/ember-cli/lib/models/blueprint.js b/packages/angular-cli/ember-cli/lib/models/blueprint.js deleted file mode 100644 index fdc88372ad2a..000000000000 --- a/packages/angular-cli/ember-cli/lib/models/blueprint.js +++ /dev/null @@ -1,1492 +0,0 @@ -'use strict'; - -/** -@module ember-cli -*/ -var FileInfo = require('./file-info'); -var Promise = require('../ext/promise'); -var chalk = require('chalk'); -var MarkdownColor = require('../utilities/markdown-color'); -var printableProperties = require('../utilities/printable-properties').blueprint; -var sequence = require('../utilities/sequence'); -var printCommand = require('../utilities/print-command'); -var fs = require('fs-extra'); -var existsSync = require('exists-sync'); -var inflector = require('inflection'); -var minimatch = require('minimatch'); -var path = require('path'); -var stat = Promise.denodeify(fs.stat); -var stringUtils = require('ember-cli-string-utils'); -var compact = require('lodash/compact'); -var intersect = require('lodash/intersection'); -var uniq = require('lodash/uniq'); -var zipObject = require('lodash/zipObject'); -var includes = require('lodash/includes'); -var any = require('lodash/some'); -var cloneDeep = require('lodash/cloneDeep'); -var keys = require('lodash/keys'); -var merge = require('lodash/merge'); -var values = require('lodash/values'); -var walkSync = require('walk-sync'); -var writeFile = Promise.denodeify(fs.outputFile); -var removeFile = Promise.denodeify(fs.remove); -var SilentError = require('silent-error'); -var CoreObject = require('../ext/core-object'); -var EOL = require('os').EOL; -var debug = require('debug')('ember-cli:blueprint'); -var normalizeEntityName = require('ember-cli-normalize-entity-name'); - -module.exports = Blueprint; - -/** - A blueprint is a bundle of template files with optional install - logic. - - Blueprints follow a simple structure. Let's take the built-in - `controller` blueprint as an example: - - ``` - blueprints/controller - ├── files - │ ├── app - │ │ └── __path__ - │ │ └── __name__.js - └── index.js - - blueprints/controller-test - ├── files - │ └── tests - │ └── unit - │ └── controllers - │ └── __test__.js - └── index.js - ``` - - ## Files - - `files` contains templates for the all the files to be - installed into the target directory. - - The `__name__` token is subtituted with the dasherized - entity name at install time. For example, when the user - invokes `ember generate controller foo` then `__name__` becomes - `foo`. When the `--pod` flag is used, for example `ember - generate controller foo --pod` then `__name__` becomes - `controller`. - - The `__path__` token is substituted with the blueprint - name at install time. For example, when the user invokes - `ember generate controller foo` then `__path__` becomes - `controller`. When the `--pod` flag is used, for example - `ember generate controller foo --pod` then `__path__` - becomes `foo` (or `/foo` if the - podModulePrefix is defined). This token is primarily for - pod support, and is only necessary if the blueprint can be - used in pod structure. If the blueprint does not require pod - support, simply use the blueprint name instead of the - `__path__` token. - - The `__test__` token is substituted with the dasherized - entity name and appended with `-test` at install time. - This token is primarily for pod support and only necessary - if the blueprint requires support for a pod structure. If - the blueprint does not require pod support, simply use the - `__name__` token instead. - - ## Template Variables (AKA Locals) - - Variables can be inserted into templates with - `<%= someVariableName %>`. - - For example, the built-in `util` blueprint - `files/app/utils/__name__.js` looks like this: - - ```js - export default function <%= camelizedModuleName %>() { - return true; - } - ``` - - `<%= camelizedModuleName %>` is replaced with the real - value at install time. - - The following template variables are provided by default: - - - `dasherizedPackageName` - - `classifiedPackageName` - - `dasherizedModuleName` - - `classifiedModuleName` - - `camelizedModuleName` - - `packageName` is the project name as found in the project's - `package.json`. - - `moduleName` is the name of the entity being generated. - - The mechanism for providing custom template variables is - described below. - - ## Index.js - - Custom installation and uninstallation behaviour can be added - by overriding the hooks documented below. `index.js` should - export a plain object, which will extend the prototype of the - `Blueprint` class. If needed, the original `Blueprint` prototype - can be accessed through the `_super` property. - - ```js - module.exports = { - locals: function(options) { - // Return custom template variables here. - return {}; - }, - - normalizeEntityName: function(entityName) { - // Normalize and validate entity name here. - return entityName; - }, - - fileMapTokens: function(options) ( - // Return custom tokens to be replaced in your files - return { - __token__: function(options){ - // logic to determine value goes here - return 'value'; - } - } - }, - - filesPath: function(options) { - return path.join(this.path, 'files'); - }, - - beforeInstall: function(options) {}, - afterInstall: function(options) {}, - beforeUninstall: function(options) {}, - afterUninstall: function(options) {} - - }; - ``` - - ## Blueprint Hooks - - As shown above, the following hooks are available to - blueprint authors: - - - `locals` - - `normalizeEntityName` - - `fileMapTokens` - - `filesPath` - - `beforeInstall` - - `afterInstall` - - `beforeUninstall` - - `afterUninstall` - - ### locals - - Use `locals` to add custom tempate variables. The method - receives one argument: `options`. Options is an object - containing general and entity-specific options. - - When the following is called on the command line: - - ```sh - ember generate controller foo --type=array --dry-run - ``` - - The object passed to `locals` looks like this: - - ```js - { - entity: { - name: 'foo', - options: { - type: 'array' - } - }, - dryRun: true - } - ``` - - This hook must return an object or a Promise which resolves to an object. - The resolved object will be merged with the aforementioned default locals. - - ### normalizeEntityName - - Use the `normalizeEntityName` hook to add custom normalization and - validation of the provided entity name. The default hook does not - make any changes to the entity name, but makes sure an entity name - is present and that it doesn't have a trailing slash. - - This hook receives the entity name as its first argument. The string - returned by this hook will be used as the new entity name. - - ### fileMapTokens - - Use `fileMapTokens` to add custom fileMap tokens for use - in the `mapFile` method. The hook must return an object in the - following pattern: - - ```js - { - __token__: function(options){ - // logic to determine value goes here - return 'value'; - } - } - ``` - - It will be merged with the default `fileMapTokens`, and can be used - to override any of the default tokens. - - Tokens are used in the files folder (see `files`), and get replaced with - values when the `mapFile` method is called. - - ### filesPath - - Use `filesPath` to define where the blueprint files to install are located. - This can be used to customize which set of files to install based on options - or environmental variables. It defaults to the `files` directory within the - blueprint's folder. - - ### beforeInstall & beforeUninstall - - Called before any of the template files are processed and receives - the the `options` and `locals` hashes as parameters. Typically used for - validating any additional command line options or for any asynchronous - setup that is needed. As an example, the `controller` blueprint validates - its `--type` option in this hook. If you need to run any asynchronous code, - wrap it in a promise and return that promise from these hooks. This will - ensure that your code is executed correctly. - - ### afterInstall & afterUninstall - - The `afterInstall` and `afterUninstall` hooks receives the same - arguments as `locals`. Use it to perform any custom work after the - files are processed. For example, the built-in `route` blueprint - uses these hooks to add and remove relevant route declarations in - `app/router.js`. - - ### Overriding Install - - If you don't want your blueprint to install the contents of - `files` you can override the `install` method. It receives the - same `options` object described above and must return a promise. - See the built-in `resource` blueprint for an example of this. - - @class Blueprint - @constructor - @extends CoreObject - @param {String} [blueprintPath] -*/ -function Blueprint(blueprintPath) { - this.path = blueprintPath; - this.name = path.basename(blueprintPath); -} - -Blueprint.__proto__ = CoreObject; -Blueprint.prototype.constructor = Blueprint; - -Blueprint.prototype.availableOptions = []; -Blueprint.prototype.anonymousOptions = ['name']; - -/** - Hook to specify the path to the blueprint's files. By default this is - `path.join(this.path, 'files)`. - - @method filesPath - @param {Object} options - @return {String} Path to the blueprints files directory. -*/ - -Blueprint.prototype.filesPath = function() { - return path.join(this.path, 'files'); -}; - -/** - Used to retrieve files for blueprint. The `file` param is an - optional string that is turned into a glob. - - @method files - @return {Array} Contents of the blueprint's files directory -*/ -Blueprint.prototype.files = function() { - if (this._files) { return this._files; } - - var filesPath = this.filesPath(this.options); - if (existsSync(filesPath)) { - this._files = walkSync(filesPath); - } else { - this._files = []; - } - - return this._files; -}; - -/** - @method srcPath - @param {String} file - @return {String} Resolved path to the file -*/ -Blueprint.prototype.srcPath = function(file) { - return path.resolve(this.filesPath(this.options), file); -}; - -/** - Hook for normalizing entity name - @method normalizeEntityName - @param {String} entityName - @return {null} -*/ -Blueprint.prototype.normalizeEntityName = function(entityName) { - return normalizeEntityName(entityName); -}; - -/** - Write a status and message to the UI - @private - @method _writeStatusToUI - @param {Function} chalkColor - @param {String} keyword - @param {String} message -*/ -Blueprint.prototype._writeStatusToUI = function(chalkColor, keyword, message) { - if (this.ui) { - this.ui.writeLine(' ' + chalkColor(keyword) + ' ' + message); - } -}; - -/** - @private - @method _writeFile - @param {Object} info - @return {Promise} -*/ -Blueprint.prototype._writeFile = function(info) { - if (!this.dryRun) { - return writeFile(info.outputPath, info.render()); - } -}; - -/** - Actions lookup - @private -*/ - -Blueprint.prototype._actions = { - write: function(info) { - this._writeStatusToUI(chalk.green, 'create', info.displayPath); - return this._writeFile(info); - }, - skip: function(info) { - var label = 'skip'; - - if (info.resolution === 'identical') { - label = 'identical'; - } - - this._writeStatusToUI(chalk.yellow, label, info.displayPath); - }, - - overwrite: function(info) { - this._writeStatusToUI(chalk.yellow, 'overwrite', info.displayPath); - return this._writeFile(info); - }, - - edit: function(info) { - this._writeStatusToUI(chalk.green, 'edited', info.displayPath); - }, - - remove: function(info) { - this._writeStatusToUI(chalk.red, 'remove', info.displayPath); - if (!this.dryRun) { - return removeFile(info.outputPath); - } - } -}; - -/** - Calls an action. - @private - @method _commit - @param {Object} result - @return {Promise} - @throws {Error} Action doesn't exist. -*/ -Blueprint.prototype._commit = function(result) { - var action = this._actions[result.action]; - - if (action) { - return action.call(this, result); - } else { - throw new Error('Tried to call action \"' + result.action + '\" but it does not exist'); - } -}; - -/** - Prints warning for pod unsupported. - @private - @method _checkForPod -*/ -Blueprint.prototype._checkForPod = function(verbose) { - if (!this.hasPathToken && this.pod && verbose) { - this.ui.writeLine(chalk.yellow('You specified the pod flag, but this' + - ' blueprint does not support pod structure. It will be generated with' + - ' the default structure.')); - } -}; - -/** - @private - @method _normalizeEntityName - @param {Object} entity -*/ -Blueprint.prototype._normalizeEntityName = function(entity) { - if (entity) { - entity.name = this.normalizeEntityName(entity.name); - } -}; - -/** - @private - @method _checkInRepoAddonExists - @param {String} inRepoAddon -*/ -Blueprint.prototype._checkInRepoAddonExists = function(inRepoAddon) { - if (inRepoAddon) { - if (!inRepoAddonExists(inRepoAddon, this.project.root)) { - throw new SilentError('You specified the in-repo-addon flag, but the' + - ' in-repo-addon \'' + inRepoAddon + '\' does not exist. Please' + - ' check the name and try again.'); - } - } -}; - -/** - @private - @method _process - @param {Object} options - @param {Function} beforeHook - @param {Function} process - @param {Function} afterHook -*/ -Blueprint.prototype._process = function(options, beforeHook, process, afterHook) { - var self = this; - var intoDir = options.target; - - return this._locals(options).then(function (locals) { - return Promise.resolve() - .then(beforeHook.bind(self, options, locals)) - .then(process.bind(self, intoDir, locals)).map(self._commit.bind(self)) - .then(afterHook.bind(self, options)); - }); -}; - -/** - @method install - @param {Object} options - @return {Promise} -*/ -Blueprint.prototype.install = function(options) { - var ui = this.ui = options.ui; - var dryRun = this.dryRun = options.dryRun; - this.project = options.project; - this.pod = options.pod; - this.options = options; - this.hasPathToken = hasPathToken(this.files()); - - podDeprecations(this.project.config(), ui); - - ui.writeLine('installing ' + this.name); - - if (dryRun) { - ui.writeLine(chalk.yellow('You specified the dry-run flag, so no' + - ' changes will be written.')); - } - - this._normalizeEntityName(options.entity); - this._checkForPod(options.verbose); - this._checkInRepoAddonExists(options.inRepoAddon); - - debug('START: processing blueprint: `%s`', this.name); - var start = new Date(); - return this._process( - options, - this.beforeInstall, - this.processFiles, - this.afterInstall).finally(function() { - debug('END: processing blueprint: `%s` in (%dms)', this.name, new Date() - start); - }.bind(this)); -}; - -/** - @method uninstall - @param {Object} options - @return {Promise} -*/ -Blueprint.prototype.uninstall = function(options) { - var ui = this.ui = options.ui; - var dryRun = this.dryRun = options.dryRun; - this.project = options.project; - this.pod = options.pod; - this.options = options; - this.hasPathToken = hasPathToken(this.files()); - - podDeprecations(this.project.config(), ui); - - ui.writeLine('uninstalling ' + this.name); - - if (dryRun) { - ui.writeLine(chalk.yellow('You specified the dry-run flag, so no' + - ' files will be deleted.')); - } - - this._normalizeEntityName(options.entity); - this._checkForPod(options.verbose); - - return this._process( - options, - this.beforeUninstall, - this.processFilesForUninstall, - this.afterUninstall); -}; - -/** - Hook for running operations before install. - @method beforeInstall - @return {Promise|null} -*/ -Blueprint.prototype.beforeInstall = function() {}; - -/** - Hook for running operations after install. - @method afterInstall - @return {Promise|null} -*/ -Blueprint.prototype.afterInstall = function() {}; - -/** - Hook for running operations before uninstall. - @method beforeUninstall - @return {Promise|null} -*/ -Blueprint.prototype.beforeUninstall = function() {}; - -/** - Hook for running operations after uninstall. - @method afterUninstall - @return {Promise|null} -*/ -Blueprint.prototype.afterUninstall = function() {}; - -/** - Hook for adding additional locals - @method locals - @return {Object|null} -*/ -Blueprint.prototype.locals = function() {}; - -/** - Hook to add additional or override existing fileMapTokens. - @method fileMapTokens - @return {Object|null} -*/ -Blueprint.prototype.fileMapTokens = function() { -}; - -/** - @private - @method _fileMapTokens - @param {Object} options - @return {Object} -*/ -Blueprint.prototype._fileMapTokens = function(options) { - var standardTokens = { - __name__: function(options) { - if (options.pod && options.hasPathToken) { - return options.blueprintName; - } - return options.dasherizedModuleName; - }, - __path__: function(options) { - var blueprintName = options.blueprintName; - - if (blueprintName.match(/-test/)) { - blueprintName = options.blueprintName.slice(0, options.blueprintName.indexOf('-test')); - } - if (options.pod && options.hasPathToken) { - return path.join(options.podPath, options.dasherizedModuleName); - } - return inflector.pluralize(blueprintName); - }, - __root__: function(options) { - if (options.inRepoAddon) { - return path.join('lib',options.inRepoAddon, 'addon'); - } - if (options.inDummy) { - return path.join('tests','dummy','app'); - } - if (options.inAddon) { - return 'addon'; - } - return 'app'; - }, - __test__: function(options) { - if (options.pod && options.hasPathToken) { - return options.blueprintName; - } - return options.dasherizedModuleName + '-test'; - } - }; - - var customTokens = this.fileMapTokens(options) || options.fileMapTokens || {}; - return merge(standardTokens, customTokens); -}; - -/** - Used to generate fileMap tokens for mapFile. - - @method generateFileMap - @param {Object} fileMapVariables - @return {Object} -*/ -Blueprint.prototype.generateFileMap = function(fileMapVariables) { - var tokens = this._fileMapTokens(fileMapVariables); - var fileMapValues = values(tokens); - var tokenValues = fileMapValues.map(function(token) { return token(fileMapVariables); }); - var tokenKeys = keys(tokens); - return zipObject(tokenKeys,tokenValues); -}; - -/** - @method buildFileInfo - @param {Function} destPath - @param {Object} templateVariables - @param {String} file - @return {FileInfo} -*/ -Blueprint.prototype.buildFileInfo = function(destPath, templateVariables, file) { - var mappedPath = this.mapFile(file, templateVariables); - - return new FileInfo({ - action: 'write', - outputPath: destPath(mappedPath), - displayPath: path.normalize(mappedPath), - inputPath: this.srcPath(file), - templateVariables: templateVariables, - ui: this.ui - }); -}; - -/** - @method isUpdate - @return {Boolean} -*/ -Blueprint.prototype.isUpdate = function() { - if (this.project && this.project.isEmberCLIProject) { - return this.project.isEmberCLIProject(); - } -}; - -/** - @private - @method _getFileInfos - @param {Array} files - @param {String} intoDir - @param {Object} templateVariables - @return {Array} file infos -*/ -Blueprint.prototype._getFileInfos = function(files, intoDir, templateVariables) { - return files.map(this.buildFileInfo.bind(this, destPath.bind(null, intoDir), templateVariables)); -}; - -/** - Add update files to ignored files - @private - @method _ignoreUpdateFiles -*/ -Blueprint.prototype._ignoreUpdateFiles = function() { - if (this.isUpdate()) { - Blueprint.ignoredFiles = Blueprint.ignoredFiles.concat(Blueprint.ignoredUpdateFiles); - } -}; - -/** - @private - @method _getFilesForInstall - @param {Array} targetFiles - @return {Array} files -*/ -Blueprint.prototype._getFilesForInstall = function(targetFiles) { - var files = this.files(); - - // if we've defined targetFiles, get file info on ones that match - return targetFiles && targetFiles.length > 0 && intersect(files, targetFiles) || files; -}; - -/** - @private - @method _checkForNoMatch - @param {Array} fileInfos - @param {String} rawArgs -*/ -Blueprint.prototype._checkForNoMatch = function(fileInfos, rawArgs) { - if (fileInfos.filter(isFilePath).length < 1 && rawArgs) { - this.ui.writeLine(chalk.yellow('The globPattern \"' + rawArgs + - '\" did not match any files, so no file updates will be made.')); - } -}; - -function finishProcessingForInstall(infos) { - infos.forEach(markIdenticalToBeSkipped); - - var infosNeedingConfirmation = infos.reduce(gatherConfirmationMessages, []); - - return sequence(infosNeedingConfirmation).returns(infos); -} - -function finishProcessingForUninstall(infos) { - infos.forEach(markToBeRemoved); - return infos; -} - -/** - @method processFiles - @param {String} intoDir - @param {Object} templateVariables -*/ -Blueprint.prototype.processFiles = function(intoDir, templateVariables) { - var files = this._getFilesForInstall(templateVariables.targetFiles); - var fileInfos = this._getFileInfos(files, intoDir, templateVariables); - this._checkForNoMatch(fileInfos, templateVariables.rawArgs); - - this._ignoreUpdateFiles(); - - return Promise.filter(fileInfos, isValidFile). - map(prepareConfirm). - then(finishProcessingForInstall); -}; - -/** - @method processFilesForUninstall - @param {String} intoDir - @param {Object} templateVariables -*/ -Blueprint.prototype.processFilesForUninstall = function(intoDir, templateVariables) { - var fileInfos = this._getFileInfos(this.files(), intoDir, templateVariables); - - this._ignoreUpdateFiles(); - - return Promise.filter(fileInfos, isValidFile). - then(finishProcessingForUninstall); -}; - - -/** - @method mapFile - @param {String} file - @return {String} -*/ -Blueprint.prototype.mapFile = function(file, locals) { - var pattern, i; - var fileMap = locals.fileMap || { __name__: locals.dasherizedModuleName }; - file = Blueprint.renamedFiles[file] || file; - for (i in fileMap) { - pattern = new RegExp(i, 'g'); - file = file.replace(pattern, fileMap[i]); - } - return file; -}; - -/** - Looks for a __root__ token in the files folder. Must be present for - the blueprint to support addon tokens. The `server`, `blueprints`, and `test` - - @private - @method supportsAddon - @return {Boolean} -*/ -Blueprint.prototype.supportsAddon = function() { - return this.files().join().match(/__root__/); -}; - -/** - @private - @method _generateFileMapVariables - @param {Object} options - @return {Object} -*/ -Blueprint.prototype._generateFileMapVariables = function(moduleName, locals, options) { - var originBlueprintName = options.originBlueprintName || this.name; - var podModulePrefix = this.project.config().podModulePrefix || ''; - var podPath = podModulePrefix.substr(podModulePrefix.lastIndexOf('/') + 1); - var inAddon = this.project.isEmberCLIAddon() || !!options.inRepoAddon; - var inDummy = this.project.isEmberCLIAddon() ? options.dummy : false; - - return { - pod: this.pod, - podPath: podPath, - hasPathToken: this.hasPathToken, - inAddon: inAddon, - inRepoAddon: options.inRepoAddon, - inDummy: inDummy, - blueprintName: this.name, - originBlueprintName: originBlueprintName, - dasherizedModuleName: stringUtils.dasherize(moduleName), - locals: locals - }; -}; - -/** - @private - @method _locals - @param {Object} options - @return {Object} -*/ -Blueprint.prototype._locals = function(options) { - var packageName = options.project.name(); - var moduleName = options.entity && options.entity.name || packageName; - var sanitizedModuleName = moduleName.replace(/\//g, '-'); - - return new Promise(function(resolve) { - resolve(this.locals(options)); - }.bind(this)).then(function (customLocals) { - var fileMapVariables = this._generateFileMapVariables(moduleName, customLocals, options); - var fileMap = this.generateFileMap(fileMapVariables); - var standardLocals = { - dasherizedPackageName: stringUtils.dasherize(packageName), - classifiedPackageName: stringUtils.classify(packageName), - dasherizedModuleName: stringUtils.dasherize(moduleName), - classifiedModuleName: stringUtils.classify(sanitizedModuleName), - camelizedModuleName: stringUtils.camelize(sanitizedModuleName), - decamelizedModuleName: stringUtils.decamelize(sanitizedModuleName), - fileMap: fileMap, - hasPathToken: this.hasPathToken, - targetFiles: options.targetFiles, - rawArgs: options.rawArgs - }; - - return merge({}, standardLocals, customLocals); - }.bind(this)); -}; - -/** - Used to add a package to the project's `package.json`. - - Generally, this would be done from the `afterInstall` hook, to - ensure that a package that is required by a given blueprint is - available. - - @method addPackageToProject - @param {String} packageName - @param {String} target - @return {Promise} -*/ -Blueprint.prototype.addPackageToProject = function(packageName, target) { - var packageObject = {name: packageName}; - - if (target) { - packageObject.target = target; - } - - return this.addPackagesToProject([packageObject]); -}; - -/** - Used to add multiple packages to the project's `package.json`. - - Generally, this would be done from the `afterInstall` hook, to - ensure that a package that is required by a given blueprint is - available. - - Expects each array item to be an object with a `name`. Each object - may optionally have a `target` to specify a specific version. - - @method addPackagesToProject - @param {Array} packages - @return {Promise} -*/ -Blueprint.prototype.addPackagesToProject = function(packages) { - var task = this.taskFor('npm-install'); - var installText = (packages.length > 1) ? 'install packages' : 'install package'; - var packageNames = []; - var packageArray = []; - - for (var i = 0; i < packages.length; i++) { - packageNames.push(packages[i].name); - - var packageNameAndVersion = packages[i].name; - - if (packages[i].target) { - packageNameAndVersion += '@' + packages[i].target; - } - - packageArray.push(packageNameAndVersion); - } - - this._writeStatusToUI(chalk.green, installText, packageNames.join(', ')); - - return task.run({ - 'save-dev': true, - verbose: false, - packages: packageArray - }); -}; - -/** - Used to remove a package from the project's `package.json`. - - Generally, this would be done from the `afterInstall` hook, to - ensure that any package conflicts can be resolved before the - addon is used. - - @method removePackageFromProject - @param {String} packageName - @return {Promise} -*/ -Blueprint.prototype.removePackageFromProject = function(packageName) { - var packageObject = {name: packageName}; - - return this.removePackagesFromProject([packageObject]); -}; - -/** - Used to remove multiple packages from the project's `package.json`. - - Generally, this would be done from the `afterInstall` hook, to - ensure that any package conflicts can be resolved before the - addon is used. - - Expects each array item to be an object with a `name` property. - - @method removePackagesFromProject - @param {Array} packages - @return {Promise} -*/ -Blueprint.prototype.removePackagesFromProject = function(packages) { - var task = this.taskFor('npm-uninstall'); - var installText = (packages.length > 1) ? 'uninstall packages' : 'uninstall package'; - var packageNames = []; - var packageArray = []; - - for (var i = 0; i < packages.length; i++) { - packageNames.push(packages[i].name); - - var packageNameAndVersion = packages[i].name; - - packageArray.push(packageNameAndVersion); - } - - this._writeStatusToUI(chalk.green, installText, packageNames.join(', ')); - - return task.run({ - 'save-dev': true, - verbose: false, - packages: packageArray - }); -}; - - -/** - Used to retrieve a task with the given name. Passes the new task - the standard information available (like `ui`, `analytics`, `project`, etc). - - @method taskFor - @param dasherizedName - @public -*/ -Blueprint.prototype.taskFor = function(dasherizedName) { - var Task = require('../tasks/' + dasherizedName); - - return new Task({ - ui: this.ui, - project: this.project - }); -}; - -/* - - Inserts the given content into a file. If the `contentsToInsert` string is already - present in the current contents, the file will not be changed unless `force` option - is passed. - - If `options.before` is specified, `contentsToInsert` will be inserted before - the first instance of that string. If `options.after` is specified, the - contents will be inserted after the first instance of that string. - If the string specified by options.before or options.after is not in the file, - no change will be made. - - If neither `options.before` nor `options.after` are present, `contentsToInsert` - will be inserted at the end of the file. - - Example: - ``` - // app/router.js - Router.map(function() { - }); - - insertIntoFile('app/router.js', - ' this.route("admin");', - {after:'Router.map(function() {'+EOL}); - - // new app/router.js - Router.map(function() { - this.route("admin"); - }); - ``` - - @method insertIntoFile - @param {String} pathRelativeToProjectRoot - @param {String} contentsToInsert - @param {Object} options - @return {Promise} -*/ -Blueprint.prototype.insertIntoFile = function(pathRelativeToProjectRoot, contentsToInsert, providedOptions) { - var fullPath = path.join(this.project.root, pathRelativeToProjectRoot); - var originalContents = ''; - - if (existsSync(fullPath)) { - originalContents = fs.readFileSync(fullPath, { encoding: 'utf8' }); - } - - var contentsToWrite = originalContents; - - var options = providedOptions || {}; - var alreadyPresent = originalContents.indexOf(contentsToInsert) > -1; - var insert = !alreadyPresent; - var insertBehavior = 'end'; - - if (options.before) { insertBehavior = 'before'; } - if (options.after) { insertBehavior = 'after'; } - - if (options.force) { insert = true; } - - if (insert) { - if (insertBehavior === 'end') { - contentsToWrite += contentsToInsert; - } else { - var contentMarker = options[insertBehavior]; - var contentMarkerIndex = contentsToWrite.indexOf(contentMarker); - - if (contentMarkerIndex !== -1) { - var insertIndex = contentMarkerIndex; - if (insertBehavior === 'after') { insertIndex += contentMarker.length; } - - contentsToWrite = contentsToWrite.slice(0, insertIndex) + - contentsToInsert + EOL + - contentsToWrite.slice(insertIndex); - } - } - } - - var returnValue = { - path: fullPath, - originalContents: originalContents, - contents: contentsToWrite, - inserted: false - }; - - if (contentsToWrite !== originalContents) { - returnValue.inserted = true; - - return writeFile(fullPath, contentsToWrite) - .then(function() { - return returnValue; - }); - } else { - return Promise.resolve(returnValue); - } -}; - -Blueprint.prototype._printCommand = printCommand; - -Blueprint.prototype.printBasicHelp = function(verbose) { - var initialMargin = ' '; - var output = initialMargin; - if (this.overridden) { - output += chalk.grey('(overridden) ' + this.name); - } else { - output += this.name; - - output += this._printCommand(initialMargin, true); - - if (verbose) { - output += EOL + this.printDetailedHelp(this.availableOptions); - } - } - - return output; -}; - -Blueprint.prototype.printDetailedHelp = function() { - var markdownColor = new MarkdownColor(); - var filePath = getDetailedHelpPath(this.path); - - if (existsSync(filePath)) { - return markdownColor.renderFile(filePath, { indent: ' ' }); - } - return ''; -}; - -Blueprint.prototype.getJson = function(verbose) { - var json = {}; - - printableProperties.forEachWithProperty(function(key) { - var value = this[key]; - if (key === 'availableOptions') { - value = cloneDeep(value); - value.forEach(function(option) { - if (typeof option.type === 'function') { - option.type = option.type.name; - } - }); - } - json[key] = value; - }, this); - - if (verbose) { - var detailedHelp = this.printDetailedHelp(this.availableOptions); - if (detailedHelp) { - json.detailedHelp = detailedHelp; - } - } - - return json; -}; - -/** - Used to retrieve a blueprint with the given name. - - @method lookupBlueprint - @param dasherizedName - @public -*/ -Blueprint.prototype.lookupBlueprint = function(dasherizedName) { - var projectPaths = this.project ? this.project.blueprintLookupPaths() : []; - - return Blueprint.lookup(dasherizedName, { - paths: projectPaths - }); -}; - -/** - @static - @method lookup - @namespace Blueprint - @param {String} [name] - @param {Object} [options] - @param {Array} [options.paths] Extra paths to search for blueprints - @param {Object} [options.properties] Properties - @return {Blueprint} -*/ -Blueprint.lookup = function(name, options) { - options = options || {}; - - var lookupPaths = generateLookupPaths(options.paths); - - var lookupPath; - var blueprintPath; - - for (var i = 0; (lookupPath = lookupPaths[i]); i++) { - blueprintPath = path.resolve(lookupPath, name); - - if (existsSync(blueprintPath)) { - return Blueprint.load(blueprintPath); - } - } - - if (!options.ignoreMissing) { - throw new SilentError('Unknown blueprint: ' + name); - } -}; - -/** - Loads a blueprint from given path. - @static - @method load - @namespace Blueprint - @param {String} blueprintPath - @return {Blueprint} blueprint instance -*/ -Blueprint.load = function(blueprintPath) { - var constructorPath = path.resolve(blueprintPath, 'index.js'); - var blueprintModule; - var Constructor = Blueprint; - - if (fs.lstatSync(blueprintPath).isDirectory()) { - - if (existsSync(constructorPath)) { - blueprintModule = require(constructorPath); - - if (typeof blueprintModule === 'function') { - Constructor = blueprintModule; - } else { - Constructor = Blueprint.extend(blueprintModule); - } - } - - return new Constructor(blueprintPath); - } - - return; -}; - -/** - @static - @method list - @namespace Blueprint - @param {Object} [options] - @param {Array} [options.paths] Extra paths to search for blueprints - @return {Blueprint} -*/ -Blueprint.list = function(options) { - options = options || {}; - - var lookupPaths = generateLookupPaths(options.paths); - var seen = []; - - return lookupPaths.map(function(lookupPath) { - var blueprints = dir(lookupPath); - var packagePath = path.join(lookupPath, '../package.json'); - var source; - - if (existsSync(packagePath)) { - source = require(packagePath).name; - } else { - source = path.basename(path.join(lookupPath, '..')); - } - - blueprints = blueprints.map(function(blueprintPath) { - var blueprint = Blueprint.load(blueprintPath); - var name; - - if (blueprint) { - name = blueprint.name; - blueprint.overridden = includes(seen, name); - seen.push(name); - - return blueprint; - } - - return; - }); - - return { - source: source, - blueprints: compact(blueprints) - }; - }); -}; - -/** - @static - @property renameFiles -*/ -Blueprint.renamedFiles = { - 'gitignore': '.gitignore' -}; - -/** - @static - @property ignoredFiles -*/ -Blueprint.ignoredFiles = [ - '.DS_Store' -]; - -/** - @static - @property ignoredUpdateFiles -*/ -Blueprint.ignoredUpdateFiles = [ - '.gitkeep', - 'app.css' -]; - -/** - @static - @property defaultLookupPaths -*/ -Blueprint.defaultLookupPaths = function() { - return [ - path.resolve(__dirname, '..', '..', 'blueprints') - ]; -}; - -/** - @private - @method prepareConfirm - @param {FileInfo} info - @return {Promise} -*/ -function prepareConfirm(info) { - return info.checkForConflict().then(function(resolution) { - info.resolution = resolution; - return info; - }); -} - -/** - @private - @method markIdenticalToBeSkipped - @param {FileInfo} info -*/ -function markIdenticalToBeSkipped(info) { - if (info.resolution === 'identical') { - info.action = 'skip'; - } -} - -/** - @private - @method markToBeRemoved - @param {FileInfo} info -*/ -function markToBeRemoved(info) { - info.action = 'remove'; -} - -/** - @private - @method gatherConfirmationMessages - @param {Array} collection - @param {FileInfo} info - @return {Array} -*/ -function gatherConfirmationMessages(collection, info) { - if (info.resolution === 'confirm') { - collection.push(info.confirmOverwriteTask()); - } - return collection; -} - -/** - @private - @method isFile - @param {FileInfo} info - @return {Boolean} -*/ -function isFile(info) { - return stat(info.inputPath).invoke('isFile'); -} - -/** - @private - @method isIgnored - @param {FileInfo} info - @return {Boolean} -*/ -function isIgnored(info) { - var fn = info.inputPath; - - return any(Blueprint.ignoredFiles, function(ignoredFile) { - return minimatch(fn, ignoredFile, { matchBase: true }); - }); -} - -/** - Combines provided lookup paths with defaults and removes - duplicates. - - @private - @method generateLookupPaths - @param {Array} lookupPaths - @return {Array} -*/ -function generateLookupPaths(lookupPaths) { - lookupPaths = lookupPaths || []; - lookupPaths = lookupPaths.concat(Blueprint.defaultLookupPaths()); - return uniq(lookupPaths); -} - -/** - Looks for a __path__ token in the files folder. Must be present for - the blueprint to support pod tokens. - - @private - @method hasPathToken - @param {files} files - @return {Boolean} -*/ -function hasPathToken(files) { - return files.join().match(/__path__/); -} - -function inRepoAddonExists(name, root) { - var addonPath = path.join(root, 'lib', name); - return existsSync(addonPath); -} - -function podDeprecations(config, ui) { - /* - var podModulePrefix = config.podModulePrefix || ''; - var podPath = podModulePrefix.substr(podModulePrefix.lastIndexOf('/') + 1); - // Disabled until we are ready to deprecate podModulePrefix - deprecateUI(ui)('`podModulePrefix` is deprecated and will be removed from future versions of ember-cli.'+ - ' Please move existing pods from \'app/' + podPath + '/\' to \'app/\'.', config.podModulePrefix); - */ - if (config.usePodsByDefault) { - ui.writeDeprecateLine('`usePodsByDefault` is no longer supported in \'config/environment.js\',' + - ' use `usePods` in \'.ember-cli\' instead.'); - } -} - -/** - @private - @method destPath - @param {String} intoDir - @param {String} file - @return {String} new path -*/ -function destPath(intoDir, file) { - return path.join(intoDir, file); -} - -/** - @private - @method isValidFile - @param {Object} fileInfo - @return {Promise} -*/ -function isValidFile(fileInfo) { - if (isIgnored(fileInfo)) { - return Promise.resolve(false); - } else { - return isFile(fileInfo); - } -} - -/** - @private - @method isFilePath - @param {Object} fileInfo - @return {Promise} -*/ -function isFilePath(fileInfo) { - return fs.statSync(fileInfo.inputPath).isFile(); -} - -/** - @private - @method dir - @returns {Array} list of files in the given directory or and empty array if no directory exists -*/ -function dir(fullPath) { - if (existsSync(fullPath)) { - return fs.readdirSync(fullPath).map(function(fileName) { - return path.join(fullPath, fileName); - }); - } else { - return []; - } -} - -/** - @private - @method getDetailedHelpPath - @param {String} thisPath - @return {String} help path -*/ -function getDetailedHelpPath(thisPath) { - return path.join(thisPath, './HELP.md'); -} diff --git a/packages/angular-cli/ember-cli/lib/models/command.js b/packages/angular-cli/ember-cli/lib/models/command.js deleted file mode 100644 index a5d89d39e123..000000000000 --- a/packages/angular-cli/ember-cli/lib/models/command.js +++ /dev/null @@ -1,505 +0,0 @@ -'use strict'; - -var nopt = require('nopt'); -var chalk = require('chalk'); -var path = require('path'); -var isGitRepo = require('is-git-url'); -var camelize = require('ember-cli-string-utils').camelize; -var getCallerFile = require('get-caller-file'); -var printableProperties = require('../utilities/printable-properties').command; -var printCommand = require('../utilities/print-command'); -var Promise = require('../ext/promise'); -var union = require('lodash/union'); -var uniq = require('lodash/uniq'); -var uniqBy = require('lodash/uniqBy'); -var map = require('lodash/map'); -var reject = require('lodash/reject'); -var filter = require('lodash/filter'); -var assign = require('lodash/assign'); -var defaults = require('lodash/defaults'); -var keys = require('lodash/keys'); -var EOL = require('os').EOL; -var CoreObject = require('../ext/core-object'); -var debug = require('debug')('ember-cli:command'); -var Watcher = require('../models/watcher'); -var SilentError = require('silent-error'); - -var allowedWorkOptions = { - insideProject: true, - outsideProject: true, - everywhere: true -}; - -path.name = 'Path'; -// extend nopt to recognize 'gitUrl' as a type -nopt.typeDefs.gitUrl = { - type: 'gitUrl', - validate: function(data, k, val) { - if (isGitRepo(val)) { - data[k] = val; - return true; - } else { - return false; - } - } -}; - -module.exports = Command; - -function Command() { - CoreObject.apply(this, arguments); - - this.isWithinProject = this.project.isEmberCLIProject(); - this.name = this.name || path.basename(getCallerFile(), '.js'); - - debug('initialize: name: %s, name: %s', this.name); - this.aliases = this.aliases || []; - - // Works Property - if (!allowedWorkOptions[this.works]) { - throw new Error('The "' + this.name + '" command\'s works field has to ' + - 'be either "everywhere", "insideProject" or "outsideProject".'); - } - - // Options properties - this.availableOptions = this.availableOptions || []; - this.anonymousOptions = this.anonymousOptions || []; - this.registerOptions(); -} -/* - Registers options with command. This method provides the ability to extend or override command options. - Expects an object containing anonymousOptions or availableOptions, which it will then merge with - existing availableOptions before building the optionsAliases which are used to define shorthands. -*/ -Command.prototype.registerOptions = function(options) { - var extendedAvailableOptions = options && options.availableOptions || []; - var extendedAnonymousOptions = options && options.anonymousOptions || []; - - this.anonymousOptions = union(this.anonymousOptions.slice(0), extendedAnonymousOptions); - - // merge any availableOptions - this.availableOptions = union(this.availableOptions.slice(0), extendedAvailableOptions); - - var optionKeys = uniq(map(this.availableOptions, 'name')); - - optionKeys.map(this.mergeDuplicateOption.bind(this)); - - this.optionsAliases = this.optionsAliases || {}; - - this.availableOptions.map(this.validateOption.bind(this)); -}; - -Command.__proto__ = CoreObject; - -Command.prototype.description = null; -Command.prototype.works = 'insideProject'; -Command.prototype.constructor = Command; -/* - Hook for extending a command before it is run in the cli.run command. - Most common use case would be to extend availableOptions. - @method beforeRun - @return {Promise|null} -*/ -Command.prototype.beforeRun = function() { - -}; - -/* - @method validateAndRun - @return {Promise} -*/ -Command.prototype.validateAndRun = function(args) { - var commandOptions = this.parseArgs(args); - // if the help option was passed, resolve with 'callHelp' to call help command - if (commandOptions && (commandOptions.options.help || commandOptions.options.h)) { - debug(this.name + ' called with help option'); - return Promise.resolve('callHelp'); - } - - if (commandOptions === null) { - return Promise.resolve(); - } - - if (this.works === 'insideProject' && !this.isWithinProject) { - return Promise.reject(new SilentError( - 'You have to be inside an ember-cli project in order to use ' + - 'the ' + chalk.green(this.name) + ' command.' - )); - } - - if (this.works === 'outsideProject' && this.isWithinProject) { - return Promise.reject(new SilentError( - 'You cannot use the ' + chalk.green(this.name) + ' command inside an ember-cli project.' - )); - } - - if (this.works === 'insideProject') { - if (!this.project.hasDependencies()) { - throw new SilentError('node_modules appears empty, you may need to run `npm install`'); - } - } - - return Watcher.detectWatcher(this.ui, commandOptions.options).then(function(options) { - if (options._watchmanInfo) { - this.project._watchmanInfo = options._watchmanInfo; - } - - return this.run(options, commandOptions.args); - }.bind(this)); -}; - -/* - Merges any options with duplicate keys in the availableOptions array. - Used primarily by registerOptions. - @method mergeDuplicateOption - @param {String} key - @return {Object} -*/ -Command.prototype.mergeDuplicateOption = function(key) { - var duplicateOptions, mergedOption, mergedAliases; - // get duplicates to merge - duplicateOptions = filter(this.availableOptions, { 'name': key }); - - if (duplicateOptions.length > 1) { - // TODO: warn on duplicates and overwriting - mergedAliases = []; - - map(duplicateOptions, 'aliases').map(function(alias) { - alias.map(function(a) { - mergedAliases.push(a); - }); - }); - - // merge duplicate options - mergedOption = assign.apply(null,duplicateOptions); - - // replace aliases with unique aliases - mergedOption.aliases = uniqBy(mergedAliases, function(alias) { - if (typeof alias === 'object') { - return alias[Object.keys(alias)[0]]; - } - return alias; - }); - - // remove duplicates from options - this.availableOptions = reject(this.availableOptions, { 'name': key }); - this.availableOptions.push(mergedOption); - } - return this.availableOptions; -}; - -/* - Normalizes option, filling in implicit values - @method normalizeOption - @param {Object} option - @return {Object} -*/ -Command.prototype.normalizeOption = function(option) { - option.key = camelize(option.name); - option.required = option.required || false; - return option; -}; - -/* - Assigns option - @method assignOption - @param {Object} option - @param {Object} parsedOptions - @param {Object} commandOptions - @return {Boolean} -*/ -Command.prototype.assignOption = function(option, parsedOptions, commandOptions) { - var isValid = isValidParsedOption(option, parsedOptions[option.name]); - if (isValid) { - if (parsedOptions[option.name] === undefined) { - if (option.default !== undefined) { - commandOptions[option.key] = option.default; - } - - if (this.settings[option.name] !== undefined) { - commandOptions[option.key] = this.settings[option.name]; - } else if (this.settings[option.key] !== undefined) { - commandOptions[option.key] = this.settings[option.key]; - } - } else { - commandOptions[option.key] = parsedOptions[option.name]; - delete parsedOptions[option.name]; - } - } else { - this.ui.writeLine('The specified command ' + chalk.green(this.name) + - ' requires the option ' + chalk.green(option.name) + '.'); - } - return isValid; -}; - -/* - Validates option - @method validateOption - @param {Object} option - @return {Boolean} -*/ -Command.prototype.validateOption = function(option) { - var parsedAliases; - - if (!option.name || !option.type) { - throw new Error('The command "' + this.name + '" has an option ' + - 'without the required type and name fields.'); - } - - if (option.name !== option.name.toLowerCase()) { - throw new Error('The "' + option.name + '" option\'s name of the "' + - this.name + '" command contains a capital letter.'); - } - - this.normalizeOption(option); - - if (option.aliases) { - parsedAliases = option.aliases.map(this.parseAlias.bind(this, option)); - return parsedAliases.map(this.assignAlias.bind(this, option)).indexOf(false) === -1; - } - return false; -}; - -/* - Parses alias for an option and adds it to optionsAliases - @method parseAlias - @param {Object} option - @param {Object|String} alias - @return {Object} -*/ -Command.prototype.parseAlias = function(option, alias) { - var aliasType = typeof alias; - var key, value, aliasValue; - - if (isValidAlias(alias, option.type)) { - if (aliasType === 'string') { - key = alias; - value = ['--' + option.name]; - } else if (aliasType === 'object') { - key = Object.keys(alias)[0]; - value = ['--' + option.name, alias[key]]; - } - } else { - if (Array.isArray(alias)) { - aliasType = 'array'; - aliasValue = alias.join(','); - } else { - aliasValue = alias; - try { - aliasValue = JSON.parse(alias); - } catch (e) { - var debug = require('debug')('angular-cli/ember-cli/models/command'); - debug(e); - } - } - throw new Error('The "' + aliasValue + '" [type:' + aliasType + - '] alias is not an acceptable value. It must be a string or single key' + - ' object with a string value (for example, "value" or { "key" : "value" }).'); - } - - return { - key: key, - value: value, - original: alias - }; - -}; -Command.prototype.assignAlias = function(option, alias) { - var isValid = this.validateAlias(option, alias); - - if (isValid) { - this.optionsAliases[alias.key] = alias.value; - } - return isValid; -}; - -/* - Validates alias value - @method validateAlias - @params {Object} alias - @return {Boolean} -*/ -Command.prototype.validateAlias = function(option, alias) { - var key = alias.key; - var value = alias.value; - - if (!this.optionsAliases[key]) { - return true; - } else { - if (value[0] !== this.optionsAliases[key][0]) { - throw new SilentError('The "' + key + '" alias is already in use by the "' + this.optionsAliases[key][0] + - '" option and cannot be used by the "' + value[0] + '" option. Please use a different alias.'); - } else { - if (value[1] !== this.optionsAliases[key][1]) { - this.ui.writeLine(chalk.yellow('The "' + key + '" alias cannot be overridden. Please use a different alias.')); - // delete offending alias from options - var index = this.availableOptions.indexOf(option); - var aliasIndex = this.availableOptions[index].aliases.indexOf(alias.original); - if (this.availableOptions[index].aliases[aliasIndex]) { - delete this.availableOptions[index].aliases[aliasIndex]; - } - } - } - return false; - } -}; - -/* - Parses command arguments and processes - @method parseArgs - @param {Object} commandArgs - @return {Object|null} -*/ -Command.prototype.parseArgs = function(commandArgs) { - var knownOpts = {}; // Parse options - var commandOptions = {}; - var parsedOptions; - - var assembleAndValidateOption = function(option) { - return this.assignOption(option, parsedOptions, commandOptions); - }; - - var validateParsed = function(key) { - // ignore 'argv', 'h', and 'help' - if (!commandOptions.hasOwnProperty(key) && key !== 'argv' && key !== 'h' && key !== 'help') { - this.ui.writeLine(chalk.yellow('The option \'--' + key + '\' is not registered with the ' + this.name + ' command. ' + - 'Run `ember ' + this.name + ' --help` for a list of supported options.')); - } - if (typeof parsedOptions[key] !== 'object') { - commandOptions[camelize(key)] = parsedOptions[key]; - } - }; - - this.availableOptions.forEach(function(option) { - if (typeof option.type !== 'string') { - knownOpts[option.name] = option.type; - } else if (option.type === 'Path') { - knownOpts[option.name] = path; - } else { - knownOpts[option.name] = String; - } - }); - - parsedOptions = nopt(knownOpts, this.optionsAliases, commandArgs, 0); - - if (!this.availableOptions.every(assembleAndValidateOption.bind(this))) { - return null; - } - - keys(parsedOptions).map(validateParsed.bind(this)); - - return { - options: defaults(commandOptions, this.settings), - args: parsedOptions.argv.remain - }; -}; - -/* - -*/ -Command.prototype.run = function(commandArgs) { - throw new Error('command must implement run' + commandArgs.toString()); -}; - -Command.prototype._printCommand = printCommand; - -/* - Prints basic help for the command. - - Basic help looks like this: - - ember generate - Generates new code from blueprints - aliases: g - --dry-run (Default: false) - --verbose (Default: false) - - The default implementation is designed to cover all bases - but may be overriden if necessary. - - @method printBasicHelp -*/ -Command.prototype.printBasicHelp = function() { - // ember command-name - var output; - if (this.isRoot) { - output = 'Usage: ' + this.name; - } else { - output = 'ng ' + this.name; - } - - output += this._printCommand(); - output += EOL; - - return output; -}; - -/* - Prints detailed help for the command. - - The default implementation is no-op and should be overridden - for each command where further help text is required. - - @method printDetailedHelp -*/ -Command.prototype.printDetailedHelp = function() {}; - -Command.prototype.getJson = function(options) { - var json = {}; - - printableProperties.forEachWithProperty(function(key) { - json[key] = this[key]; - }, this); - - if (this.addAdditionalJsonForHelp) { - this.addAdditionalJsonForHelp(json, options); - } - - return json; -}; - -/* - Validates options parsed by nopt -*/ -function isValidParsedOption(option, parsedOption) { - // option.name didn't parse - if (parsedOption === undefined) { - // no default - if (option.default === undefined) { - if (option.required) { - return false; - } - } - } - - return true; -} - -/* - Validates alias. Must be a string or single key object -*/ -function isValidAlias(alias, expectedType) { - var type = typeof alias; - var value, valueType; - if (type === 'string') { - return true; - } else if (type === 'object') { - - // no arrays, no multi-key objects - if (!Array.isArray(alias) && Object.keys(alias).length === 1) { - value = alias[Object.keys(alias)[0]]; - valueType = typeof value; - if (!Array.isArray(expectedType)) { - if (valueType === expectedType.name.toLowerCase()) { - return true; - } - } else { - if (expectedType.indexOf(value) > -1) { - return true; - } - } - } - } - - return false; -} diff --git a/packages/angular-cli/ember-cli/lib/models/edit-file-diff.js b/packages/angular-cli/ember-cli/lib/models/edit-file-diff.js deleted file mode 100644 index eeeb5d155ccf..000000000000 --- a/packages/angular-cli/ember-cli/lib/models/edit-file-diff.js +++ /dev/null @@ -1,63 +0,0 @@ -'use strict'; - -var fs = require('fs'); -var Promise = require('../ext/promise'); -var readFile = Promise.denodeify(fs.readFile); -var writeFile = Promise.denodeify(fs.writeFile); -var jsdiff = require('diff'); -var quickTemp = require('quick-temp'); -var path = require('path'); -var SilentError = require('silent-error'); -var openEditor = require('../utilities/open-editor'); - -function EditFileDiff(options) { - this.info = options.info; - - quickTemp.makeOrRemake(this, 'tmpDifferenceDir'); -} - -EditFileDiff.prototype.edit = function() { - return Promise.hash({ - input: this.info.render(), - output: readFile(this.info.outputPath) - }) - .then(invokeEditor.bind(this)) - .then(applyPatch.bind(this)) - .finally(cleanUp.bind(this)); -}; - -function cleanUp() { - quickTemp.remove(this, 'tmpDifferenceDir'); // jshint ignore:line -} - -function applyPatch(resultHash) { - /*jshint validthis:true */ - return Promise.hash({ - diffString: readFile(resultHash.diffPath), - currentString: readFile(resultHash.outputPath) - }).then(function(result) { - var appliedDiff = jsdiff.applyPatch(result.currentString.toString(), result.diffString.toString()); - - if (!appliedDiff) { - var message = 'Patch was not cleanly applied.'; - this.info.ui.writeLine(message + ' Please choose another action.'); - throw new SilentError(message); - } - - return writeFile(resultHash.outputPath, appliedDiff); - }.bind(this)); -} - -function invokeEditor(result) { - var info = this.info; // jshint ignore:line - var diff = jsdiff.createPatch(info.outputPath, result.output.toString(), result.input); - var diffPath = path.join(this.tmpDifferenceDir, 'currentDiff.diff'); // jshint ignore:line - - return writeFile(diffPath, diff).then(function() { - return openEditor(diffPath); - }).then(function() { - return { outputPath: info.outputPath, diffPath: diffPath }; - }); -} - -module.exports = EditFileDiff; diff --git a/packages/angular-cli/ember-cli/lib/models/file-info.js b/packages/angular-cli/ember-cli/lib/models/file-info.js deleted file mode 100644 index d029a0335b21..000000000000 --- a/packages/angular-cli/ember-cli/lib/models/file-info.js +++ /dev/null @@ -1,173 +0,0 @@ -'use strict'; - -var fs = require('fs'); -var Promise = require('../ext/promise'); -var readFile = Promise.denodeify(fs.readFile); -var lstat = Promise.denodeify(fs.stat); -var chalk = require('chalk'); -var EditFileDiff = require('./edit-file-diff'); -var EOL = require('os').EOL; -var isBinaryFile = require('isbinaryfile'); -var template = require('lodash/template'); -var canEdit = require('../utilities/open-editor').canEdit; - -function processTemplate(content, context) { - var options = { - evaluate: /<%([\s\S]+?)%>/g, - interpolate: /<%=([\s\S]+?)%>/g, - escape: /<%-([\s\S]+?)%>/g - }; - return template(content, options)(context); -} - -function diffHighlight(line) { - if (line[0] === '+') { - return chalk.green(line); - } else if (line[0] === '-') { - return chalk.red(line); - } else if (line.match(/^@@/)) { - return chalk.cyan(line); - } else { - return line; - } -} - -FileInfo.prototype.confirmOverwrite = function(path) { - var promptOptions = { - type: 'expand', - name: 'answer', - default: false, - message: chalk.red('Overwrite') + ' ' + path + '?', - choices: [ - { key: 'y', name: 'Yes, overwrite', value: 'overwrite' }, - { key: 'n', name: 'No, skip', value: 'skip' }, - { key: 'd', name: 'Diff', value: 'diff' } - ] - }; - - if (canEdit()) { - promptOptions.choices.push({ key: 'e', name: 'Edit', value: 'edit' }); - } - - return this.ui.prompt(promptOptions) - .then(function(response) { - return response.answer; - }); -}; - -FileInfo.prototype.displayDiff = function() { - var info = this, - jsdiff = require('diff'); - return Promise.hash({ - input: this.render(), - output: readFile(info.outputPath) - }).then(function(result) { - var diff = jsdiff.createPatch( - info.outputPath, result.output.toString(), result.input - ); - var lines = diff.split('\n'); - - for (var i = 0; i < lines.length; i++) { - info.ui.write( - diffHighlight(lines[i] + EOL) - ); - } - }); -}; - -function FileInfo(options) { - this.action = options.action; - this.outputPath = options.outputPath; - this.displayPath = options.displayPath; - this.inputPath = options.inputPath; - this.templateVariables = options.templateVariables; - this.ui = options.ui; -} - -FileInfo.prototype.render = function() { - var path = this.inputPath, - context = this.templateVariables; - if (!this.rendered) { - this.rendered = readFile(path).then(function(content) { - return lstat(path).then(function(fileStat) { - if (isBinaryFile(content, fileStat.size)) { - return content; - } else { - try { - return processTemplate(content.toString(), context); - } catch (err) { - err.message += ' (Error in blueprint template: ' + path + ')'; - throw err; - } - } - }); - }); - } - return this.rendered; -}; - -FileInfo.prototype.checkForConflict = function() { - return new Promise(function (resolve, reject) { - fs.exists(this.outputPath, function (doesExist, error) { - if (error) { - reject(error); - return; - } - - var result; - - if (doesExist) { - result = Promise.hash({ - input: this.render(), - output: readFile(this.outputPath) - }).then(function(result) { - var type; - if (result.input === result.output.toString()) { - type = 'identical'; - } else { - type = 'confirm'; - } - return type; - }.bind(this)); - } else { - result = 'none'; - } - - resolve(result); - }.bind(this)); - }.bind(this)); -}; - -FileInfo.prototype.confirmOverwriteTask = function() { - var info = this; - - return function() { - return new Promise(function(resolve, reject) { - function doConfirm() { - info.confirmOverwrite(info.displayPath).then(function(action) { - if (action === 'diff') { - info.displayDiff().then(doConfirm, reject); - } else if (action === 'edit') { - var editFileDiff = new EditFileDiff({info: info}); - editFileDiff.edit().then(function() { - info.action = action; - resolve(info); - }).catch(function() { - doConfirm() - .finally(function() { - resolve(info); - }); - }); - } else { - info.action = action; - resolve(info); - } - }, reject); - } - - doConfirm(); - }); - }.bind(this); -}; - -module.exports = FileInfo; diff --git a/packages/angular-cli/ember-cli/lib/models/installation-checker.js b/packages/angular-cli/ember-cli/lib/models/installation-checker.js deleted file mode 100644 index 2037aca45b72..000000000000 --- a/packages/angular-cli/ember-cli/lib/models/installation-checker.js +++ /dev/null @@ -1,59 +0,0 @@ -'use strict'; - -var debug = require('debug')('ember-cli:installation-checker'); -var fs = require('fs'); -var existsSync = require('exists-sync'); -var path = require('path'); -var SilentError = require('silent-error'); - -module.exports = InstallationChecker; - -function InstallationChecker(options) { - this.project = options.project; -} - -/** -* Check if npm directories are present, -* and raise an error message with instructions on how to proceed. -* -* If some of these package managers aren't being used in the project -* we just ignore them. Their usage is considered by checking the -* presence of your manifest files: package.json for npm. -*/ -InstallationChecker.prototype.checkInstallations = function() { - var commands = []; - - if (this.usingNpm() && this.npmDependenciesNotPresent()) { - debug('npm dependencies not installed'); - commands.push('`npm install`'); - } - if (commands.length) { - var commandText = commands.join(' and '); - throw new SilentError('No dependencies installed. Run ' + commandText + ' to install missing dependencies.'); - } -}; - -function hasDependencies(pkg) { - return (pkg.dependencies && pkg.dependencies.length) || - (pkg.devDependencies && pkg.devDependencies.length); -} - -function readJSON(path) { - try { - return JSON.parse(fs.readFileSync(path).toString()); - } catch (e) { - throw new SilentError('InstallationChecker: Unable to parse: ' + path); - } -} - -InstallationChecker.prototype.hasNpmDeps = function() { - return hasDependencies(readJSON(path.join(this.project.root, 'package.json'))); -}; - -InstallationChecker.prototype.usingNpm = function() { - return existsSync(path.join(this.project.root, 'package.json')) && this.hasNpmDeps(); -}; - -InstallationChecker.prototype.npmDependenciesNotPresent = function() { - return !existsSync(this.project.nodeModulesPath); -}; diff --git a/packages/angular-cli/ember-cli/lib/models/project.js b/packages/angular-cli/ember-cli/lib/models/project.js deleted file mode 100644 index fda4ed524a51..000000000000 --- a/packages/angular-cli/ember-cli/lib/models/project.js +++ /dev/null @@ -1,676 +0,0 @@ -'use strict'; - -/** -@module ember-cli -*/ -var Promise = require('../ext/promise'); -var path = require('path'); -var findup = Promise.denodeify(require('findup')); -var resolve = Promise.denodeify(require('resolve')); -var fs = require('fs'); -var existsSync = require('exists-sync'); -var find = require('lodash/find'); -var assign = require('lodash/assign'); -var forOwn = require('lodash/forOwn'); -var merge = require('lodash/merge'); -var debug = require('debug')('ember-cli:project'); -var AddonDiscovery = require('../models/addon-discovery'); -var AddonsFactory = require('../models/addons-factory'); -var Command = require('../models/command'); -var UI = require('../ui'); -var nodeModulesPath = require('node-modules-path'); -var getPackageBaseName = require('../utilities/get-package-base-name'); -var versionUtils = require('../utilities/version-utils'); -var emberCLIVersion = versionUtils.emberCLIVersion; - -/** - The Project model is tied to your package.json. It is instiantiated - by giving Project.closest the path to your project. - - @class Project - @constructor - @param {String} root Root directory for the project - @param {Object} pkg Contents of package.json -*/ -function Project(root, pkg, ui, cli) { - debug('init root: %s', root); - this.root = root; - this.pkg = pkg; - this.ui = ui; - this.cli = cli; - this.addonPackages = {}; - this.addons = []; - this.liveReloadFilterPatterns = []; - this.setupNodeModulesPath(); - this.addonDiscovery = new AddonDiscovery(this.ui); - this.addonsFactory = new AddonsFactory(this, this); - this._watchmanInfo = { - enabled: false, - version: null, - canNestRoots: false - }; -} - -Project.prototype.hasDependencies = function() { - return !!this.nodeModulesPath; -}; -/** - Sets the path to the node_modules directory for this - project. - - @private - @method setupNodeModulesPath - */ -Project.prototype.setupNodeModulesPath = function() { - this.nodeModulesPath = nodeModulesPath(this.root); - debug('nodeModulesPath: %s', this.nodeModulesPath); -}; - -var processCwd = process.cwd(); -// ensure NULL_PROJECT is a singleton -var NULL_PROJECT; - -Project.nullProject = function (ui, cli) { - if (NULL_PROJECT) { return NULL_PROJECT; } - - NULL_PROJECT = new Project(processCwd, {}, ui, cli); - - NULL_PROJECT.isEmberCLIProject = function() { - return false; - }; - - NULL_PROJECT.isEmberCLIAddon = function() { - return false; - }; - - NULL_PROJECT.name = function() { - return path.basename(process.cwd()); - }; - - NULL_PROJECT.initializeAddons(); - - return NULL_PROJECT; -}; - -/** - Returns the name from package.json. - - @private - @method name - @return {String} Package name - */ -Project.prototype.name = function() { - return getPackageBaseName(this.pkg.name); -}; - -/** - Returns whether or not this is an Ember CLI project. - This checks whether ember-cli is listed in devDependencies. - - @private - @method isEmberCLIProject - @return {Boolean} Whether this is an Ember CLI project - */ -Project.prototype.isEmberCLIProject = function() { - return (this.cli ? this.cli.npmPackage : 'ember-cli') in this.dependencies(); -}; - -/** - Returns whether or not this is an Ember CLI addon. - - @method isEmberCLIAddon - @return {Boolean} Whether or not this is an Ember CLI Addon. - */ -Project.prototype.isEmberCLIAddon = function() { - return !!this.pkg.keywords && this.pkg.keywords.indexOf('ember-addon') > -1; -}; - -/** - Returns the path to the configuration. - - @private - @method configPath - @return {String} Configuration path - */ -Project.prototype.configPath = function() { - var configPath = 'config'; - - if (this.pkg['ember-addon'] && this.pkg['ember-addon']['configPath']) { - configPath = this.pkg['ember-addon']['configPath']; - } - - return path.join(configPath, 'environment'); -}; - -/** - Loads the configuration for this project and its addons. - - @private - @method config - @param {String} env Environment name - @return {Object} Merged confiration object - */ -Project.prototype.config = function(env) { - var configPath = this.configPath(); - - if (existsSync(path.join(this.root, configPath + '.js'))) { - var appConfig = this.require('./' + configPath)(env); - var addonsConfig = this.getAddonsConfig(env, appConfig); - - return merge(addonsConfig, appConfig); - } else { - return this.getAddonsConfig(env, {}); - } -}; - -/** - Returns the addons configuration. - - @private - @method getAddonsConfig - @param {String} env Environment name - @param {Object} appConfig Application configuration - @return {Object} Merged configuration of all addons - */ -Project.prototype.getAddonsConfig = function(env, appConfig) { - this.initializeAddons(); - - var initialConfig = merge({}, appConfig); - - return this.addons.reduce(function(config, addon) { - if (addon.config) { - merge(config, addon.config(env, config)); - } - - return config; - }, initialConfig); -}; - -/** - Returns whether or not the given file name is present in this project. - - @private - @method has - @param {String} file File name - @return {Boolean} Whether or not the file is present - */ -Project.prototype.has = function(file) { - return existsSync(path.join(this.root, file)) || existsSync(path.join(this.root, file + '.js')); -}; - -/** - Resolves the absolute path to a file. - - @private - @method resolve - @param {String} file File to resolve - @return {String} Absolute path to file - */ -Project.prototype.resolve = function(file) { - return resolve(file, { - basedir: this.root - }); -}; - -/** - Resolves the absolute path to a file synchronously - - @private - @method resolveSync - @param {String} file File to resolve - @return {String} Absolute path to file - */ -Project.prototype.resolveSync = function(file) { - return resolve.sync(file, { - basedir: this.root - }); -}; - -/** - Calls `require` on a given module. - - @private - @method require - @param {String} file File path or module name - @return {Object} Imported module - */ -Project.prototype.require = function(file) { - if (/^\.\//.test(file)) { // Starts with ./ - return require(path.join(this.root, file)); - } else { - return require(path.join(this.nodeModulesPath, file)); - } -}; - - -Project.prototype.emberCLIVersion = emberCLIVersion; - -/** - Returns the dependencies from a package.json - - @private - @method dependencies - @param {Object} pkg Package object. If false, the current package is used. - @param {Boolean} excludeDevDeps Whether or not development dependencies should be excluded, defaults to false. - @return {Object} Dependencies - */ -Project.prototype.dependencies = function(pkg, excludeDevDeps) { - pkg = pkg || this.pkg || {}; - - var devDependencies = pkg['devDependencies']; - if (excludeDevDeps) { - devDependencies = {}; - } - - return assign({}, devDependencies, pkg['dependencies']); -}; - -/** - Provides the list of paths to consult for addons that may be provided - internally to this project. Used for middleware addons with built-in support. - - @private - @method supportedInternalAddonPaths -*/ -Project.prototype.supportedInternalAddonPaths = function() { - if (!this.root) { return []; } - - var internalMiddlewarePath = path.join(__dirname, '../tasks/server/middleware'); - - return [ - path.join(internalMiddlewarePath, 'tests-server'), - path.join(internalMiddlewarePath, 'history-support'), - path.join(internalMiddlewarePath, 'serve-files'), - path.join(internalMiddlewarePath, 'proxy-server') - ]; -}; - -/** - Discovers all addons for this project and stores their names and - package.json contents in this.addonPackages as key-value pairs - - @private - @method discoverAddons - */ -Project.prototype.discoverAddons = function() { - var addonsList = this.addonDiscovery.discoverProjectAddons(this); - - this.addonPackages = this.addonDiscovery.addonPackages(addonsList); -}; - -/** - Loads and initializes all addons for this project. - - @private - @method initializeAddons - */ -Project.prototype.initializeAddons = function() { - if (this._addonsInitialized) { - return; - } - this._addonsInitialized = true; - - debug('initializeAddons for: %s', this.name()); - - this.discoverAddons(); - - this.addons = this.addonsFactory.initializeAddons(this.addonPackages); - - this.addons.forEach(function(addon) { - debug('addon: %s', addon.name); - }); -}; - -/** - Returns what commands are made available by addons by inspecting - `includedCommands` for every addon. - - @private - @method addonCommands - @return {Object} Addon names and command maps as key-value pairs - */ -Project.prototype.addonCommands = function() { - var commands = {}; - this.addons.forEach(function(addon) { - var includedCommands = (addon.includedCommands && addon.includedCommands()) || {}; - var addonCommands = {}; - - for (var key in includedCommands) { - if (typeof includedCommands[key] === 'function') { - addonCommands[key] = includedCommands[key]; - } else { - addonCommands[key] = Command.extend(includedCommands[key]); - } - } - if (Object.keys(addonCommands).length) { - commands[addon.name] = addonCommands; - } - }); - return commands; -}; - -/** - Execute a given callback for every addon command. - Example: - - ``` - project.eachAddonCommand(function(addonName, commands) { - console.log('Addon ' + addonName + ' exported the following commands:' + commands.keys().join(', ')); - }); - ``` - - @private - @method eachAddonCommand - @param {Function} callback [description] - */ -Project.prototype.eachAddonCommand = function(callback) { - if (this.initializeAddons && this.addonCommands) { - this.initializeAddons(); - var addonCommands = this.addonCommands(); - - forOwn(addonCommands, function(commands, addonName) { - return callback(addonName, commands); - }); - } -}; - -/** - Path to the blueprints for this project. - - @private - @method localBlueprintLookupPath - @return {String} Path to blueprints - */ -Project.prototype.localBlueprintLookupPath = function() { - return path.join(this.root, 'blueprints'); -}; - -/** - Returns a list of paths (including addon paths) where blueprints will be looked up. - - @private - @method blueprintLookupPaths - @return {Array} List of paths - */ -Project.prototype.blueprintLookupPaths = function() { - if (this.isEmberCLIProject()) { - var lookupPaths = [this.localBlueprintLookupPath()]; - var addonLookupPaths = this.addonBlueprintLookupPaths(); - - return lookupPaths.concat(addonLookupPaths); - } else { - return this.addonBlueprintLookupPaths(); - } -}; - -/** - Returns a list of addon paths where blueprints will be looked up. - - @private - @method addonBlueprintLookupPaths - @return {Array} List of paths - */ -Project.prototype.addonBlueprintLookupPaths = function() { - var addonPaths = this.addons.map(function(addon) { - if (addon.blueprintsPath) { - return addon.blueprintsPath(); - } - }, this); - - return addonPaths.filter(Boolean).reverse(); -}; - -/** - Reloads package.json - - @private - @method reloadPkg - @return {Object} Package content - */ -Project.prototype.reloadPkg = function() { - var pkgPath = path.join(this.root, 'package.json'); - - // We use readFileSync instead of require to avoid the require cache. - this.pkg = JSON.parse(fs.readFileSync(pkgPath, { encoding: 'utf-8' })); - - return this.pkg; -}; - -/** - Re-initializes addons. - - @private - @method reloadAddons - */ -Project.prototype.reloadAddons = function() { - this.reloadPkg(); - this._addonsInitialized = false; - return this.initializeAddons(); -}; - -/** - Find an addon by its name - - @private - @method findAddonByName - @param {String} name Addon name as specified in package.json - @return {Addon} Addon instance - */ -Project.prototype.findAddonByName = function(name) { - this.initializeAddons(); - - var exactMatch = find(this.addons, function(addon) { - return name === addon.name || (addon.pkg && name === addon.pkg.name); - }); - - if (exactMatch) { - return exactMatch; - } - - return find(this.addons, function(addon) { - return name.indexOf(addon.name) > -1 || (addon.pkg && name.indexOf(addon.pkg.name) > -1); - }); -}; - -/** - Generate test file contents. - - This method is supposed to be overwritten by test framework addons - like `ember-cli-qunit` and `ember-cli-mocha`. - - @public - @method generateTestFile - @param {String} moduleName Name of the test module (e.g. `JSHint`) - @param {Object[]} tests Array of tests with `name`, `passed` and `errorMessage` properties - @return {String} The test file content - */ -Project.prototype.generateTestFile = function(/* moduleName, tests */) { - var message = 'Please install an Ember.js test framework addon or update your dependencies.'; - - if (this.ui) { - this.ui.writeDeprecateLine(message) - } else { - console.warn(message); - } - - return ''; -}; - -/** - Returns a new project based on the first package.json that is found - in `pathName`. - - @private - @static - @method closest - @param {String} pathName Path to your project - @return {Promise} Promise which resolves to a {Project} - */ -Project.closest = function(pathName, _ui, _cli) { - var ui = ensureUI(_ui); - return closestPackageJSON(pathName) - .then(function(result) { - debug('closest %s -> %s', pathName, result); - if (result.pkg && result.pkg.name === 'ember-cli') { - return Project.nullProject(_ui, _cli); - } - - return new Project(result.directory, result.pkg, ui, _cli); - }) - .catch(function(reason) { - handleFindupError(pathName, reason); - }); -}; - -/** - Returns a new project based on the first package.json that is found - in `pathName`. - - @private - @static - @method closestSync - @param {String} pathName Path to your project - @param {UI} _ui The UI instance to provide to the created Project. - @return {Project} Project instance - */ -Project.closestSync = function(pathName, _ui, _cli) { - var ui = ensureUI(_ui); - var directory, pkg; - - if (_cli && _cli.testing) { - directory = existsSync(path.join(pathName, 'package.json')) && process.cwd(); - if (!directory) { - if (pathName.indexOf(path.sep + 'app') > -1) { - directory = findupPath(pathName); - } else { - pkg = {name: 'ember-cli'}; - } - } - } else { - directory = findupPath(pathName); - } - if (!pkg) { - pkg = JSON.parse(fs.readFileSync(path.join(directory, 'package.json'))); - } - - debug('dir' + directory); - debug('pkg: %s', pkg); - if (pkg && pkg.name === 'ember-cli') { - return Project.nullProject(_ui, _cli); - } - - debug('closestSync %s -> %s', pathName, directory); - return new Project(directory, pkg, ui, _cli); -}; - -/** - Returns a new project based on the first package.json that is found - in `pathName`, or the nullProject. - - The nullProject signifies no-project, but abides by the null object pattern - - @private - @static - @method projectOrnullProject - @param {UI} _ui The UI instance to provide to the created Project. - @return {Project} Project instance - */ -Project.projectOrnullProject = function(_ui, _cli) { - try { - return Project.closestSync(process.cwd(), _ui, _cli); - } catch (reason) { - if (reason instanceof Project.NotFoundError) { - return Project.nullProject(_ui, _cli); - } else { - throw reason; - } - } -}; - -/** - Returns the project root based on the first package.json that is found - - @return {String} The project root directory - */ -Project.getProjectRoot = function () { - try { - var directory = findup.sync(process.cwd(), 'package.json'); - var pkg = require(path.join(directory, 'package.json')); - - if (pkg && pkg.name === 'ember-cli') { - debug('getProjectRoot: named \'ember-cli\'. Will use cwd: %s', process.cwd()); - return process.cwd(); - } - - debug('getProjectRoot %s -> %s', process.cwd(), directory); - return directory; - } catch (reason) { - if (isFindupError(reason)) { - debug('getProjectRoot: not found. Will use cwd: %s', process.cwd()); - return process.cwd(); - } else { - throw reason; - } - } -}; - -function NotFoundError(message) { - this.name = 'NotFoundError'; - this.message = message; - this.stack = (new Error()).stack; -} - -NotFoundError.prototype = Object.create(Error.prototype); -NotFoundError.prototype.constructor = NotFoundError; - -Project.NotFoundError = NotFoundError; - -function ensureUI(_ui) { - var ui = _ui; - - if (!ui) { - // TODO: one UI (lib/cli/index.js also has one for now...) - ui = new UI({ - inputStream: process.stdin, - outputStream: process.stdout, - ci: process.env.CI || /^(dumb|emacs)$/.test(process.env.TERM), - writeLevel: ~process.argv.indexOf('--silent') ? 'ERROR' : undefined - }); - } - - return ui; -} - -function closestPackageJSON(pathName) { - return findup(pathName, 'package.json') - .then(function(directory) { - return Promise.hash({ - directory: directory, - pkg: require(path.join(directory, 'package.json')) - }); - }); -} - -function findupPath(pathName) { - try { - return findup.sync(pathName, 'package.json'); - } catch (reason) { - handleFindupError(pathName, reason); - } -} - -function isFindupError(reason) { - // Would be nice if findup threw error subclasses - return reason && /not found/i.test(reason.message); -} - -function handleFindupError(pathName, reason) { - if (isFindupError(reason)) { - throw new NotFoundError('No project found at or up from: `' + pathName + '`'); - } else { - throw reason; - } -} - -// Export -module.exports = Project; diff --git a/packages/angular-cli/ember-cli/lib/models/task.js b/packages/angular-cli/ember-cli/lib/models/task.js deleted file mode 100644 index 4000f3395b8a..000000000000 --- a/packages/angular-cli/ember-cli/lib/models/task.js +++ /dev/null @@ -1,15 +0,0 @@ -'use strict'; - -var CoreObject = require('../ext/core-object'); - -function Task() { - CoreObject.apply(this, arguments); -} - -module.exports = Task; - -Task.__proto__ = CoreObject; - -Task.prototype.run = function(/*options*/) { - throw new Error('Task needs to have run() defined.'); -}; diff --git a/packages/angular-cli/ember-cli/lib/models/watcher.js b/packages/angular-cli/ember-cli/lib/models/watcher.js deleted file mode 100644 index 3f4ca00ca45d..000000000000 --- a/packages/angular-cli/ember-cli/lib/models/watcher.js +++ /dev/null @@ -1,114 +0,0 @@ -'use strict'; - -var chalk = require('chalk'); -var Task = require('./task'); -var debug = require('debug')('ember-cli:watcher'); -var Promise = require('../ext/promise'); -var exec = Promise.denodeify(require('child_process').exec); -var isWin = /^win/.test(process.platform); - -var Watcher = Task.extend({ - verbose: true, - - init: function() { - var options = this.buildOptions(); - - debug('initialize %o', options); - }, - - didError: function(error) { - debug('didError %o', error); - this.ui.writeError(error); - }, - - then: function() { - // return this.watcher.then.apply(this.watcher, arguments); - }, - - didChange: function(results) { - debug('didChange %o', results); - var totalTime = results.totalTime / 1e6; - - this.ui.writeLine(''); - this.ui.writeLine(chalk.green('Build successful - ' + Math.round(totalTime) + 'ms.')); - }, - - on: function() { - // this.watcher.on.apply(this.watcher, arguments); - }, - - off: function() { - // this.watcher.off.apply(this.watcher, arguments); - }, - buildOptions: function() { - var watcher = this.options && this.options.watcher; - - if (watcher && ['polling', 'watchman', 'node', 'events'].indexOf(watcher) === -1) { - throw new Error('Unknown watcher type --watcher=[polling|watchman|node] but was: ' + watcher); - } - - return { - verbose: this.verbose, - poll: watcher === 'polling', - watchman: watcher === 'watchman' || watcher === 'events', - node: watcher === 'node' - }; - } -}); - -Watcher.detectWatcher = function(ui, _options) { - var options = _options || {}; - var watchmanInfo = 'Visit http://ember-cli.com/user-guide/#watchman for more info.'; - - if (options.watcher === 'polling') { - debug('skip detecting watchman, poll instead'); - return Promise.resolve(options); - } else if (options.watcher === 'node') { - debug('skip detecting watchman, node instead'); - return Promise.resolve(options); - } else if (isWin) { - debug('watchman isn\'t supported on windows, node instead'); - options.watcher = 'node'; - return Promise.resolve(options); - } else { - debug('detecting watchman'); - return exec('watchman version').then(function(output) { - var version; - try { - version = JSON.parse(output).version; - } catch (e) { - options.watcher = 'node'; - ui.writeLine('Looks like you have a different program called watchman, falling back to NodeWatcher.'); - ui.writeLine(watchmanInfo); - return options; - } - debug('detected watchman: %s', version); - - var semver = require('semver'); - if (semver.satisfies(version, '>= 3.0.0')) { - debug('watchman %s does satisfy: %s', version, '>= 3.0.0'); - options.watcher = 'watchman'; - options._watchmanInfo = { - enabled: true, - version: version, - canNestRoots: semver.satisfies(version, '>= 3.7.0') - }; - } else { - debug('watchman %s does NOT satisfy: %s', version, '>= 3.0.0'); - ui.writeLine('Invalid watchman found, version: [' + version + '] did not satisfy [>= 3.0.0], falling back to NodeWatcher.'); - ui.writeLine(watchmanInfo); - options.watcher = 'node'; - } - - return options; - }, function(reason) { - debug('detecting watchman failed %o', reason); - ui.writeLine('Could not start watchman; falling back to NodeWatcher for file system events.'); - ui.writeLine(watchmanInfo); - options.watcher = 'node'; - return options; - }); - } -}; - -module.exports = Watcher; diff --git a/packages/angular-cli/ember-cli/lib/tasks.js b/packages/angular-cli/ember-cli/lib/tasks.js deleted file mode 100644 index efb5b22acbaf..000000000000 --- a/packages/angular-cli/ember-cli/lib/tasks.js +++ /dev/null @@ -1,11 +0,0 @@ - -module.exports = { - CreateAndStepIntoDirectory: require('./tasks/create-and-step-into-directory'), - DestroyFromBlueprint: require('./tasks/destroy-from-blueprint'), - GenerateFromBlueprint: require('./tasks/generate-from-blueprint'), - GitInit: require('./tasks/git-init'), - InstallBlueprint: require('./tasks/install-blueprint'), - NpmInstall: require('./tasks/npm-install'), - NpmTask: require('./tasks/npm-task'), - NpmUninstall: require('./tasks/npm-uninstall'), -}; diff --git a/packages/angular-cli/ember-cli/lib/tasks/create-and-step-into-directory.js b/packages/angular-cli/ember-cli/lib/tasks/create-and-step-into-directory.js deleted file mode 100644 index b4fea890fbb8..000000000000 --- a/packages/angular-cli/ember-cli/lib/tasks/create-and-step-into-directory.js +++ /dev/null @@ -1,46 +0,0 @@ -'use strict'; - -// Creates a directory with the name directoryName in cwd and then sets cwd to -// this directory. - -var Promise = require('../ext/promise'); -var fs = require('fs'); -var existsSync = require('exists-sync'); -var mkdir = Promise.denodeify(fs.mkdir); -var Task = require('../models/task'); -var SilentError = require('silent-error'); - -module.exports = Task.extend({ - // Options: String directoryName, Boolean: dryRun - - warnDirectoryAlreadyExists: function warnDirectoryAlreadyExists() { - var message = 'Directory \'' + this.directoryName + '\' already exists.'; - return new SilentError(message); - }, - - run: function(options) { - var directoryName = this.directoryName = options.directoryName; - if (options.dryRun) { - return new Promise(function(resolve, reject) { - if (existsSync(directoryName)) { - return reject(this.warnDirectoryAlreadyExists()); - } - resolve(); - }.bind(this)); - } - - return mkdir(directoryName) - .catch(function(err) { - if (err.code === 'EEXIST') { - throw this.warnDirectoryAlreadyExists(); - } else { - throw err; - } - }.bind(this)) - .then(function() { - var cwd = process.cwd(); - process.chdir(directoryName); - return { initialDirectory: cwd }; - }); - } -}); diff --git a/packages/angular-cli/ember-cli/lib/tasks/destroy-from-blueprint.js b/packages/angular-cli/ember-cli/lib/tasks/destroy-from-blueprint.js deleted file mode 100644 index bc50961b25fa..000000000000 --- a/packages/angular-cli/ember-cli/lib/tasks/destroy-from-blueprint.js +++ /dev/null @@ -1,9 +0,0 @@ -/*jshint quotmark: false*/ - -'use strict'; - -var Generate = require('./generate-from-blueprint'); - -module.exports = Generate.extend({ - blueprintFunction: 'uninstall' -}); diff --git a/packages/angular-cli/ember-cli/lib/tasks/generate-from-blueprint.js b/packages/angular-cli/ember-cli/lib/tasks/generate-from-blueprint.js deleted file mode 100644 index fe7f2b93e8ce..000000000000 --- a/packages/angular-cli/ember-cli/lib/tasks/generate-from-blueprint.js +++ /dev/null @@ -1,95 +0,0 @@ -/*jshint quotmark: false*/ - -'use strict'; - -var Promise = require('../ext/promise'); -var Blueprint = require('../models/blueprint'); -var Task = require('../models/task'); -var parseOptions = require('../utilities/parse-options'); -var merge = require('lodash/merge'); - -module.exports = Task.extend({ - blueprintFunction: 'install', - - run: function(options) { - var self = this; - var name = options.args[0]; - var noAddonBlueprint = ['mixin', 'blueprint-test']; - - var mainBlueprint = this.lookupBlueprint(name, options.ignoreMissingMain); - var testBlueprint = this.lookupBlueprint(name + '-test', true); - // lookup custom addon blueprint - var addonBlueprint = this.lookupBlueprint(name + '-addon', true); - // otherwise, use default addon-import - - if (noAddonBlueprint.indexOf(name) < 0 && !addonBlueprint && (mainBlueprint && mainBlueprint.supportsAddon()) && options.args[1]) { - addonBlueprint = this.lookupBlueprint('addon-import', true); - } - - if (options.ignoreMissingMain && !mainBlueprint) { - return Promise.resolve(); - } - - if (options.dummy) { - // don't install test or addon reexport for dummy - if (this.project.isEmberCLIAddon()) { - testBlueprint = null; - addonBlueprint = null; - } - } - - var entity = { - name: options.args[1], - options: parseOptions(options.args.slice(2)) - }; - - var blueprintOptions = { - target: this.project.root, - entity: entity, - ui: this.ui, - project: this.project, - settings: this.settings, - testing: this.testing, - taskOptions: options, - originBlueprintName: name - }; - - blueprintOptions = merge(blueprintOptions, options || {}); - - return mainBlueprint[this.blueprintFunction](blueprintOptions) - .then(function() { - if (!testBlueprint) { return; } - - if (testBlueprint.locals === Blueprint.prototype.locals) { - testBlueprint.locals = function(options) { - return mainBlueprint.locals(options); - }; - } - - var testBlueprintOptions = merge({} , blueprintOptions, { installingTest: true }); - - return testBlueprint[self.blueprintFunction](testBlueprintOptions); - }) - .then(function() { - if (!addonBlueprint || name.match(/-addon/)) { return; } - if (!this.project.isEmberCLIAddon() && blueprintOptions.inRepoAddon === null) { return; } - - if (addonBlueprint.locals === Blueprint.prototype.locals) { - addonBlueprint.locals = function(options) { - return mainBlueprint.locals(options); - }; - } - - var addonBlueprintOptions = merge({}, blueprintOptions, { installingAddon: true }); - - return addonBlueprint[self.blueprintFunction](addonBlueprintOptions); - }.bind(this)); - }, - - lookupBlueprint: function(name, ignoreMissing) { - return Blueprint.lookup(name, { - paths: this.project.blueprintLookupPaths(), - ignoreMissing: ignoreMissing - }); - } -}); diff --git a/packages/angular-cli/ember-cli/lib/tasks/git-init.js b/packages/angular-cli/ember-cli/lib/tasks/git-init.js deleted file mode 100644 index 2152003c3505..000000000000 --- a/packages/angular-cli/ember-cli/lib/tasks/git-init.js +++ /dev/null @@ -1,50 +0,0 @@ -'use strict'; - -var Promise = require('../../lib/ext/promise'); -var Task = require('../models/task'); -var exec = Promise.denodeify(require('child_process').exec); -var path = require('path'); -var pkg = require('../../../package.json'); -var fs = require('fs'); -var template = require('lodash/template'); - -var gitEnvironmentVariables = { - GIT_AUTHOR_NAME: 'Tomster', - GIT_AUTHOR_EMAIL: 'tomster@emberjs.com', - get GIT_COMMITTER_NAME() { return this.GIT_AUTHOR_NAME; }, - get GIT_COMMITTER_EMAIL() { return this.GIT_AUTHOR_EMAIL; } -}; - -module.exports = Task.extend({ - run: function(commandOptions) { - var chalk = require('chalk'); - var ui = this.ui; - - if (commandOptions.skipGit) { - return Promise.resolve(); - } - - return exec('git --version') - .then(function() { - return exec('git init') - .then(function() { - return exec('git add .'); - }) - .then(function() { - var commitTemplate = fs.readFileSync(path.join(__dirname, '../utilities/COMMIT_MESSAGE.txt')); - var commitMessage = template(commitTemplate)(pkg); - return exec('git commit -m "' + commitMessage + '"', {env: gitEnvironmentVariables}); - }) - .then(function() { - ui.writeLine(chalk.green('Successfully initialized git.')); - }); - }) - .catch(function(error) { - if (commandOptions.logErrors) { - ui.writeError(error); - } - // if git is not found or an error was thrown during the `git` - // init process just swallow any errors here - }); - } -}); diff --git a/packages/angular-cli/ember-cli/lib/tasks/install-blueprint.js b/packages/angular-cli/ember-cli/lib/tasks/install-blueprint.js deleted file mode 100644 index ffb0ce573275..000000000000 --- a/packages/angular-cli/ember-cli/lib/tasks/install-blueprint.js +++ /dev/null @@ -1,55 +0,0 @@ -'use strict'; - -var Blueprint = require('../models/blueprint'); -var Task = require('../models/task'); -var Promise = require('../ext/promise'); -var isGitRepo = require('is-git-url'); -var temp = require('temp'); -var childProcess = require('child_process'); -var path = require('path'); -var merge = require('lodash/merge'); - -var mkdir = Promise.denodeify(temp.mkdir); -var exec = Promise.denodeify(childProcess.exec); - -module.exports = Task.extend({ - run: function(options) { - var cwd = process.cwd(); - var name = options.rawName; - var blueprintOption = options.blueprint; - // If we're in a dry run, pretend we changed directories. - // Pretending we cd'd avoids prompts in the actual current directory. - var fakeCwd = path.join(cwd, name); - var target = options.dryRun ? fakeCwd : cwd; - - var installOptions = { - target: target, - entity: { name: name }, - ui: this.ui, - project: this.project, - dryRun: options.dryRun, - targetFiles: options.targetFiles, - rawArgs: options.rawArgs - }; - - installOptions = merge(installOptions, options || {}); - - if (isGitRepo(blueprintOption)) { - return mkdir('ember-cli').then(function(pathName) { - var execArgs = ['git', 'clone', blueprintOption, pathName].join(' '); - return exec(execArgs).then(function() { - return exec('npm install', {cwd: pathName}).then(function() { - var blueprint = Blueprint.load(pathName); - return blueprint.install(installOptions); - }); - }); - }); - } else { - var blueprintName = blueprintOption || 'app'; - var blueprint = Blueprint.lookup(blueprintName, { - paths: this.project.blueprintLookupPaths() - }); - return blueprint.install(installOptions); - } - } -}); diff --git a/packages/angular-cli/ember-cli/lib/tasks/npm-install.js b/packages/angular-cli/ember-cli/lib/tasks/npm-install.js deleted file mode 100644 index 40fa58af9cbe..000000000000 --- a/packages/angular-cli/ember-cli/lib/tasks/npm-install.js +++ /dev/null @@ -1,11 +0,0 @@ -'use strict'; - -// Runs `npm install` in cwd - -var NpmTask = require('./npm-task'); - -module.exports = NpmTask.extend({ - command: 'install', - startProgressMessage: 'Installing packages for tooling via npm', - completionMessage: 'Installed packages for tooling via npm.' -}); diff --git a/packages/angular-cli/ember-cli/lib/tasks/npm-task.js b/packages/angular-cli/ember-cli/lib/tasks/npm-task.js deleted file mode 100644 index 048e184983ce..000000000000 --- a/packages/angular-cli/ember-cli/lib/tasks/npm-task.js +++ /dev/null @@ -1,64 +0,0 @@ -'use strict'; - -// Runs `npm install` in cwd - -var chalk = require('chalk'); -var Task = require('../models/task'); -var Promise = require('../ext/promise'); - -var spawn = Promise.denodeify(require('child_process').spawn); - - -module.exports = Task.extend({ - // The command to run: can be 'install' or 'uninstall' - command: '', - // Message to send to ui.startProgress - startProgressMessage: '', - // Message to send to ui.writeLine on completion - completionMessage: '', - - init: function() { - }, - // Options: Boolean verbose - run: function(options) { - this.ui.startProgress(chalk.green(this.startProgressMessage), chalk.green('.')); - - var npmOptions = { - loglevel: options.verbose ? 'verbose' : 'error', - logstream: this.ui.outputStream, - color: 'always', - // by default, do install peoples optional deps - 'optional': 'optional' in options ? options.optional : true, - 'save-dev': !!options['save-dev'], - 'save-exact': !!options['save-exact'] - }; - - var packages = options.packages || []; - - // npm otherwise is otherwise noisy, already submitted PR for npm to fix - // misplaced console.log - this.disableLogger(); - return spawn('npm', [this.command].concat(packages, npmOptions)). - // return npm(this.command, packages, npmOptions, this.npm). - finally(this.finally.bind(this)). - then(this.announceCompletion.bind(this)); - }, - - announceCompletion: function() { - this.ui.writeLine(chalk.green(this.completionMessage)); - }, - - finally: function() { - this.ui.stopProgress(); - this.restoreLogger(); - }, - - disableLogger: function() { - this.oldLog = console.log; - console.log = function() {}; - }, - - restoreLogger: function() { - console.log = this.oldLog; // Hack, see above - } -}); diff --git a/packages/angular-cli/ember-cli/lib/tasks/npm-uninstall.js b/packages/angular-cli/ember-cli/lib/tasks/npm-uninstall.js deleted file mode 100644 index 79db8f8a0ace..000000000000 --- a/packages/angular-cli/ember-cli/lib/tasks/npm-uninstall.js +++ /dev/null @@ -1,11 +0,0 @@ -'use strict'; - -// Runs `npm uninstall` in cwd - -var NpmTask = require('./npm-task'); - -module.exports = NpmTask.extend({ - command: 'uninstall', - startProgressMessage: 'Uninstalling packages for tooling via npm', - completionMessage: 'Uninstalled packages for tooling via npm.' -}); diff --git a/packages/angular-cli/ember-cli/lib/ui/index.js b/packages/angular-cli/ember-cli/lib/ui/index.js deleted file mode 100644 index 0188c900a789..000000000000 --- a/packages/angular-cli/ember-cli/lib/ui/index.js +++ /dev/null @@ -1,231 +0,0 @@ -'use strict'; - -var ora = require('ora'); -var Promise = require('../ext/promise'); -var EOL = require('os').EOL; -var chalk = require('chalk'); -var writeError = require('./write-error'); - -var DEFAULT_WRITE_LEVEL = 'INFO'; - -// Note: You should use `ui.outputStream`, `ui.inputStream` and `ui.write()` -// instead of `process.stdout` and `console.log`. -// Thus the pleasant progress indicator automatically gets -// interrupted and doesn't mess up the output! -> Convenience :P - -module.exports = UI; - -/* - @constructor - - The UI provides the CLI with a unified mechanism for providing output and - requesting input from the user. This becomes useful when wanting to adjust - logLevels, or mock input/output for tests. - - new UI({ - inputStream: process.stdin, - outputStream: process.stdout, - writeLevel: 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR', - ci: true | false - }); - -**/ - -function UI(options) { - var spinner = this.spinner = ora({ color: 'green' }); - - this.through = require('through'); - this.readline = require('readline2'); - - // Output stream - this.actualOutputStream = options.outputStream; - this.outputStream = this.through(function(data) { - spinner.stop(); - this.emit('data', data); - }); - - this.outputStream.setMaxListeners(0); - this.outputStream.pipe(this.actualOutputStream); - - this.inputStream = options.inputStream; - this.errorStream = options.errorStream; - - this.errorLog = options.errorLog || []; - this.writeLevel = options.writeLevel || DEFAULT_WRITE_LEVEL; - this.ci = !!options.ci; -} - -/** - Unified mechanism to write a string to the console. - Optionally include a writeLevel, this is used to decide if the specific - logging mechanism should or should not be printed. - - @method write - @param {String} data - @param {Number} writeLevel -*/ -UI.prototype.write = function(data, writeLevel) { - if (writeLevel === 'ERROR') { - this.errorStream.write(data); - } else if (this.writeLevelVisible(writeLevel)) { - this.outputStream.write(data); - } -}; - -/** - Unified mechanism to write a string and new line to the console. - Optionally include a writeLevel, this is used to decide if the specific - logging mechanism should or should not be printed. - @method writeLine - @param {String} data - @param {Number} writeLevel -*/ -UI.prototype.writeLine = function(data, writeLevel) { - this.write(data + EOL, writeLevel); -}; - -/** - Helper method to write a string with the DEBUG writeLevel and gray chalk - @method writeDebugLine - @param {String} data -*/ -UI.prototype.writeDebugLine = function(data) { - this.writeLine(chalk.gray(data), 'DEBUG'); -}; - -/** - Helper method to write a string with the INFO writeLevel and cyan chalk - @method writeInfoLine - @param {String} data -*/ -UI.prototype.writeInfoLine = function(data) { - this.writeLine(chalk.cyan(data), 'INFO'); -}; - -/** - Helper method to write a string with the WARNING writeLevel and yellow chalk. - Optionally include a test. If falsy, the warning will be printed. By default, warnings - will be prepended with WARNING text when printed. - @method writeWarnLine - @param {String} data - @param {Boolean} test - @param {Boolean} prepend -*/ -UI.prototype.writeWarnLine = function(data, test, prepend) { - if (test) { return; } - - data = this.prependLine('WARNING', data, prepend); - this.writeLine(chalk.yellow(data), 'WARNING', test); -}; - -/** - Helper method to write a string with the WARNING writeLevel and yellow chalk. - Optionally include a test. If falsy, the deprecation will be printed. By default deprecations - will be prepended with DEPRECATION text when printed. - @method writeDeprecateLine - @param {String} data - @param {Boolean} test - @param {Boolean} prepend -*/ -UI.prototype.writeDeprecateLine = function(data, test, prepend) { - data = this.prependLine('DEPRECATION', data, prepend); - this.writeWarnLine(data, test, false); -}; - -/** - Utility method to prepend a line with a flag-like string (i.e., WARNING). - @method prependLine - @param {String} prependData - @param {String} data - @param {Boolean} prepend -*/ -UI.prototype.prependLine = function(prependData, data, prepend) { - if (typeof prepend === 'undefined' || prepend) { - data = prependData + ': ' + data; - } - - return data; -}; - -/** - Unified mechanism to an Error to the console. - This will occure at a writeLevel of ERROR - - @method writeError - @param {Error} error -*/ -UI.prototype.writeError = function(error) { - writeError(this, error); -}; - -/** - Sets the write level for the UI. Valid write levels are 'DEBUG', 'INFO', - 'WARNING', and 'ERROR'. - - @method setWriteLevel - @param {String} level -*/ -UI.prototype.setWriteLevel = function(level) { - if (Object.keys(this.WRITE_LEVELS).indexOf(level) === -1) { - throw new Error('Unknown write level. Valid values are \'DEBUG\', \'INFO\', \'WARNING\', and \'ERROR\'.'); - } - - this.writeLevel = level; -}; - -UI.prototype.startProgress = function(message/*, stepString*/) { - if (this.writeLevelVisible('INFO')) { - if (this.ci) { - this.writeLine(message); - } else { - this.spinner.text = message; - this.spinner.start(); - } - } -}; - -UI.prototype.stopProgress = function() { - if (this.writeLevelVisible('INFO') && !this.ci) { - this.spinner.stop(); - } -}; - -UI.prototype.prompt = function(questions, callback) { - var inquirer = require('inquirer'); - - // If no callback was provided, automatically return a promise - if (callback) { - inquirer.prompt(questions, callback); - } else { - return new Promise(function(resolve) { - inquirer.prompt(questions, resolve); - }); - } -}; - -/** - @property WRITE_LEVELS - @private - @type Object -*/ -UI.prototype.WRITE_LEVELS = { - 'DEBUG': 1, - 'INFO': 2, - 'WARNING': 3, - 'ERROR': 4 -}; - -/** - Whether or not the specified write level should be printed by this UI. - - @method writeLevelVisible - @private - @param {String} writeLevel - @return {Boolean} -*/ -UI.prototype.writeLevelVisible = function(writeLevel) { - var levels = this.WRITE_LEVELS; - writeLevel = writeLevel || DEFAULT_WRITE_LEVEL; - - return levels[writeLevel] >= levels[this.writeLevel]; -}; diff --git a/packages/angular-cli/ember-cli/lib/ui/write-error.js b/packages/angular-cli/ember-cli/lib/ui/write-error.js deleted file mode 100644 index 5114f9da409e..000000000000 --- a/packages/angular-cli/ember-cli/lib/ui/write-error.js +++ /dev/null @@ -1,25 +0,0 @@ -'use strict'; -var chalk = require('chalk'); - -module.exports = function writeError(ui, error) { - if (!error) { return; } - - // Uglify errors have a filename instead - var fileName = error.file || error.filename; - if (fileName) { - if (error.line) { - fileName += error.col ? ' (' + error.line + ':' + error.col + ')' : ' (' + error.line + ')'; - } - ui.writeLine(chalk.red('File: ' + fileName), 'ERROR'); - } - - if (error.message) { - ui.writeLine(chalk.red(error.message), 'ERROR'); - } else { - ui.writeLine(chalk.red(error), 'ERROR'); - } - - if (error.stack) { - ui.writeLine(error.stack, 'ERROR'); - } -}; diff --git a/packages/angular-cli/ember-cli/lib/utilities/COMMIT_MESSAGE.txt b/packages/angular-cli/ember-cli/lib/utilities/COMMIT_MESSAGE.txt deleted file mode 100644 index e1c35a53b875..000000000000 --- a/packages/angular-cli/ember-cli/lib/utilities/COMMIT_MESSAGE.txt +++ /dev/null @@ -1,39 +0,0 @@ -Initial Commit from Ember CLI v<%= version %> - - _..., - ,:^;,...; - -+===;. ,,--++====++-,,, .: /....., - :::::~+++++#:,+#++++++++++++++++++#*..: /,...... - (,,,,,,::=+++##++++++++++++++++++++++#. :....../ - ...,,,,,::++++++++++++++++++++++++++++++*..,...: - *..+...,#@@@@@@@@@++++++++++++++++++++++#*....* - @#,;##############@@@+*+#@@@@@@@@@@#*++#..< - *@##@@+,-*^^^*-+@####@@@######@@@#####@@,,,+ - @#@* @#@@@@#@@+--*^^*--#@@@@@@# - @#@. @# @##+++@#, .@@#@@ - #@# @@ +@@++++#@@ @@ :@@ - :@#* @#@++++++@#* #@ @@+ - :*+@@#;,.__.+@#@+,-^^.++@# @@++ - ;* :*@@@##@@@@;++r._j^.+@##@+,.__,,@@++. - /* ........+++++++++++++#@@@@@###@@#++++, - ,: ...,@@@#++===----==@@@####,,....+++++ - .: ......@@##@\ ; :@####@,,...... +++. - ; .........@###, ; ;xx#@;,,..... *;+, - | ........,*;xxxx--^--=xxx,........ :+#; - ; ......,,;xxxxxxxxxxxxx;,..... *+# - ; ......,::xxxx;. ...... +. . - *; ......... +### .... / ,. /:| ,. - .+: ... ;##++##, . ,#. (..v..;*./ - ** ## ###* .:*&&&+. \.,....<, - #&+**==-..,,__ ;## ### :,*+&&&&&&&v+#&,,.._/ - #&&&&*...,::,,. ##; ,##* .*****;:&&&&&&&&& - ,+*+;~*..*** *.* ### ###* ******* *+#&;* - ##,;## **** :, ** - ##### ## ### ###, ######## .##### ;## ## - ####### ;## #### ,###. ########## ######## ### #### - ### ### ### ########## #### #### ,## ### #######* - ### ,### ##############: ## ### #### ,## :#### ### ##; - ########## ########### ## .## ,### ####### ##### :###### - ###### .###### #### ## ### ### ######* :##### #### - ############# #### ################ ######## ### - #####* *#* #: :### *###* *#### #* diff --git a/packages/angular-cli/ember-cli/lib/utilities/DAG.js b/packages/angular-cli/ember-cli/lib/utilities/DAG.js deleted file mode 100644 index 432783c93efd..000000000000 --- a/packages/angular-cli/ember-cli/lib/utilities/DAG.js +++ /dev/null @@ -1,110 +0,0 @@ -'use strict'; - -function visit(vertex, fn, visited, path) { - var name = vertex.name, - vertices = vertex.incoming, - names = vertex.incomingNames, - len = names.length, - i; - if (!visited) { - visited = {}; - } - if (!path) { - path = []; - } - if (visited.hasOwnProperty(name)) { - return; - } - path.push(name); - visited[name] = true; - for (i = 0; i < len; i++) { - visit(vertices[names[i]], fn, visited, path); - } - fn(vertex, path); - path.pop(); -} - -function DAG() { - this.names = []; - this.vertices = {}; -} - -DAG.prototype.add = function(name) { - if (!name) { return; } - if (this.vertices.hasOwnProperty(name)) { - return this.vertices[name]; - } - var vertex = { - name: name, - incoming: {}, - incomingNames: [], - hasOutgoing: false, - value: null - }; - - this.vertices[name] = vertex; - this.names.push(name); - return vertex; -}; - -DAG.prototype.map = function(name, value) { - this.add(name).value = value; -}; - -DAG.prototype.addEdge = function(fromName, toName) { - if (!fromName || !toName || fromName === toName) { - return; - } - var from = this.add(fromName), to = this.add(toName); - if (to.incoming.hasOwnProperty(fromName)) { - return; - } - function checkCycle(vertex, path) { - if (vertex.name === toName) { - throw new Error('cycle detected: ' + toName + ' <- ' + path.join(' <- ')); - } - } - visit(from, checkCycle); - from.hasOutgoing = true; - to.incoming[fromName] = from; - to.incomingNames.push(fromName); -}; - -DAG.prototype.topsort = function(fn) { - var visited = {}, - vertices = this.vertices, - names = this.names, - len = names.length, - i, vertex; - for (i = 0; i < len; i++) { - vertex = vertices[names[i]]; - if (!vertex.hasOutgoing) { - visit(vertex, fn, visited); - } - } -}; - -DAG.prototype.addEdges = function(name, value, before, after) { - var i; - this.map(name, value); - if (before) { - if (typeof before === 'string') { - this.addEdge(name, before); - } else { - for (i = 0; i < before.length; i++) { - this.addEdge(name, before[i]); - } - } - } - if (after) { - if (typeof after === 'string') { - this.addEdge(after, name); - } else { - for (i = 0; i < after.length; i++) { - this.addEdge(after[i], name); - } - } - } -}; - -module.exports = DAG; diff --git a/packages/angular-cli/ember-cli/lib/utilities/get-option-args.js b/packages/angular-cli/ember-cli/lib/utilities/get-option-args.js deleted file mode 100644 index 3ce3bd7838c2..000000000000 --- a/packages/angular-cli/ember-cli/lib/utilities/get-option-args.js +++ /dev/null @@ -1,15 +0,0 @@ -'use strict'; - -module.exports = function(option, commandArgs) { - var results = [], value, i; - var optionIndex = commandArgs.indexOf(option); - if (optionIndex === -1) { return results; } - - for (i = optionIndex + 1; i < commandArgs.length; i++) { - value = commandArgs[i]; - if (/^\-+/.test(value)) { break; } - results.push(value); - } - - return results; -}; diff --git a/packages/angular-cli/ember-cli/lib/utilities/get-package-base-name.js b/packages/angular-cli/ember-cli/lib/utilities/get-package-base-name.js deleted file mode 100644 index 9690ce43bb30..000000000000 --- a/packages/angular-cli/ember-cli/lib/utilities/get-package-base-name.js +++ /dev/null @@ -1,12 +0,0 @@ -'use strict'; - -module.exports = function (name) { - var packageParts; - - if (!name) { - return null; - } - - packageParts = name.split('/'); - return packageParts[(packageParts.length - 1)]; -}; diff --git a/packages/angular-cli/ember-cli/lib/utilities/markdown-color.js b/packages/angular-cli/ember-cli/lib/utilities/markdown-color.js deleted file mode 100644 index 6bf3c343a741..000000000000 --- a/packages/angular-cli/ember-cli/lib/utilities/markdown-color.js +++ /dev/null @@ -1,213 +0,0 @@ -'use strict'; - -var fs = require('fs'); -var existsSync = require('exists-sync'); - -var chalk = require('chalk'); -var SilentError = require('silent-error'); -var isArray = require('lodash/isArray'); -var merge = require('lodash/merge'); - -var colors = ['red', 'green', 'blue', 'cyan', 'magenta', 'yellow', 'black', 'white', 'grey', 'gray']; -var backgroundColors = ['bgRed', 'bgGreen', 'bgBlue', 'bgCyan', 'bgMagenta', 'bgYellow', 'bgWhite', 'bgBlack']; - -module.exports = MarkdownColor; - -/* - Parses markdown and applies coloring to output by matching defined tokens and - applying styling. Default outputs chalk.js coloring for terminal output. - Options: - tokens: pass additional tokens in the following format: - { - name:{ - token: '(Reserved)', // example of token - pattern: /(Reserved)/g, // regexp pattern - render: function(string) { return string + '!'} // function that takes and returns a string - } - } - - markdownOptions: object containing options to pass (or override) to markdown-it-terminal upon - instantiation. See https://github.com/trabus/markdown-it-terminal/blob/master/index.js#L8 - - renderStyles: pass an object to render color styles, default is chalk.js - - @class MarkdownColor - @constructor - @param {Object} [options] -*/ -function MarkdownColor(options) { - var optionTokens = options && options.tokens || {}; - var renderStyles = options && options.renderStyles || chalk; - var tokens = this.generateTokens(renderStyles); - var markdownOptions = options && options.markdownOptions || {}; - var markdown = require('markdown-it'); - var terminal = require('markdown-it-terminal'); - this.options = options || {}; - this.markdown = markdown().use(terminal, markdownOptions); - this.tokens = merge(tokens, optionTokens); - - return this; -} - -/* - Lookup markdown file and render contents - @method renderFile - @param {String} [filePath] - @param {Object} [options] -*/ -MarkdownColor.prototype.renderFile = function (filePath, options) { - var file; - if (existsSync(filePath)) { - file = fs.readFileSync(filePath, 'utf-8'); - } else { - throw new SilentError('The file \'' + filePath + '\' doesn\'t exist.' + - ' Please check your filePath'); - } - - return this.render(file, options); -}; - -/* - Parse markdown and output as string - @method render - @param {String} [string] - @param {Object} [options] - @return {String} -*/ -MarkdownColor.prototype.render = function (string, options) { - var indent = options && options.indent || ''; - var input = this.markdown.render(string); - var styles = Object.keys(this.tokens); - input = input.replace(/^/mg, indent); - styles.reverse().map(function(style) { - input = input.replace(this.tokens[style].pattern, this.tokens[style].render); - }.bind(this)); - input = input.replace(/\~\^(.*)\~\^/g, escapeToken); - return input; -}; - -/* - Generate default style tokens - @method generateTokens - @return {Object} -*/ -MarkdownColor.prototype.generateTokens = function (renderer) { - var defaultTokens = { - // ember-cli styles - option: { - name: 'option', - token: '--option', - pattern: /((--\w*\b)|())/g, - render: renderStylesFactory(renderer, 'cyan') - }, - default: { - name: 'default', - token: '(Default: default)', - pattern: /(\(Default:\s.*\))/g, - render: renderStylesFactory(renderer, 'cyan') - }, - required: { - name: 'required', - token: '(Required)', - pattern: /(\(Required\))/g, - render: renderStylesFactory(renderer, 'cyan') - } - }; - - var colorTokens = unshiftValue(colors.concat(backgroundColors).map(getToken), {}).reduce(setToken); - return merge(colorTokens, defaultTokens); -}; - - -/* - Looks up multiple styles to apply to the rendered output - @method renderStylesFactory - @param {Object} [renderer] should match chalk.js format - @param {String, Array} [styleNames] - @return {Function} Function that will wrap the input string with styling -*/ -MarkdownColor.prototype.renderStylesFactory = renderStylesFactory; -function renderStylesFactory(renderer, styleNames) { - var styles; - if (isArray(styleNames)) { - styles = styleNames.map(checkStyleName.bind(null, renderer)); - } else { - styles = [checkStyleName(renderer, styleNames)]; - } - return function(match, pattern) { - return styles.reverse().reduce(function (previous, current) { - return renderer[current](previous); - }, pattern); - }; -} - -/* - Check to see if style exists on the renderer - @param {Object} [renderer] should match chalk.js format - @param {String} [name] -*/ -function checkStyleName(renderer, name) { - if (Object.keys(renderer.styles).indexOf(name) > -1) { - return name; - } else { - throw new SilentError('The style \'' + name + '\' is not supported by the markdown renderer.'); - } -} - -/* - @param {String} [name] - @param {Object} [options] - @return {RegExp} Returns lookup pattern -*/ -function getColorTokenRegex(name, options) { - options = options || {}; - var start = options.start || '(?:<'; - var end = options.end || '>)'; - var close = options.close || '(?:<\/'; - var middle = options.middle || '(.*?)'; - var tag = start + name + end; - var closeTag = close + name + end; - var pattern = tag + middle + closeTag; - return new RegExp(pattern, 'g'); -} - -/* - @param {String} [name] - @param {Object} [options] - @return {Object} Returns token object -*/ -function getToken(name, options) { - var renderer = options && options.renderer || chalk; - return { - name: name, - token: '<' + name + '>', - pattern: getColorTokenRegex(name), - render: renderStylesFactory(renderer, name) - }; -} - -function setToken(result, color) { - result[color.name] = color; - return result; -} - -function escapeToken(match, pattern) { - var output = pattern.replace(/\~/g,''); - return '<' + output + '>'; -} - -function unshiftValue(array, value) { - array.unshift(value); - return array; -} - -/* -Formatting colors for ember-cli help - -white: ember serve -yellow: -cyan: --port, --important-option -cyan: (Default: something), (Default: 4200) -white: Description 1, Description 2 -cyan: (Required) -*/ diff --git a/packages/angular-cli/ember-cli/lib/utilities/merge-blueprint-options.js b/packages/angular-cli/ember-cli/lib/utilities/merge-blueprint-options.js deleted file mode 100644 index 0c5fc4f4c872..000000000000 --- a/packages/angular-cli/ember-cli/lib/utilities/merge-blueprint-options.js +++ /dev/null @@ -1,31 +0,0 @@ -'use strict'; - -var SilentError = require('silent-error'); -var Blueprint = require('../models/blueprint'); - -/* - * Helper for commands that use a blueprint to merge the blueprint's options - * into the command's options so they can be passed in. Needs to be invoked - * with `this` pointing to the command object, e.g. - * - * var mergeBlueprintOptions = require('../utilities/merge-blueprint-options'); - * - * Command.extend({ - * beforeRun: mergeBlueprintOptions - * }) - */ -module.exports = function(rawArgs) { - if (rawArgs.length === 0) { - return; - } - // merge in blueprint availableOptions - var blueprint; - try { - blueprint = Blueprint.lookup(rawArgs[0], { - paths: this.project.blueprintLookupPaths() - }); - this.registerOptions(blueprint); - } catch (e) { - SilentError.debugOrThrow('ember-cli/commands/' + this.name, e); - } -}; diff --git a/packages/angular-cli/ember-cli/lib/utilities/mk-tmp-dir-in.js b/packages/angular-cli/ember-cli/lib/utilities/mk-tmp-dir-in.js deleted file mode 100644 index 4c1ce06a30f1..000000000000 --- a/packages/angular-cli/ember-cli/lib/utilities/mk-tmp-dir-in.js +++ /dev/null @@ -1,25 +0,0 @@ -'use strict'; - -var Promise = require('../ext/promise'); -var fs = require('fs-extra'); -var temp = require('temp'); -var mkdir = Promise.denodeify(fs.mkdir); -var mkdirTemp = Promise.denodeify(temp.mkdir); - -function exists(dir) { - return new Promise(function(resolve) { - fs.exists(dir, resolve); - }); -} - -function mkTmpDirIn(dir) { - return exists(dir).then(function(doesExist) { - if (!doesExist) { - return mkdir(dir); - } - }).then(function() { - return mkdirTemp({ dir: dir }); - }); -} - -module.exports = mkTmpDirIn; diff --git a/packages/angular-cli/ember-cli/lib/utilities/normalize-blueprint-option.js b/packages/angular-cli/ember-cli/lib/utilities/normalize-blueprint-option.js deleted file mode 100644 index 1bedf9543c41..000000000000 --- a/packages/angular-cli/ember-cli/lib/utilities/normalize-blueprint-option.js +++ /dev/null @@ -1,7 +0,0 @@ -'use strict'; - -var path = require('path'); - -module.exports = function normalizeBlueprintOption(blueprint) { - return blueprint[0] === '.' ? path.resolve(process.cwd(), blueprint) : blueprint; -}; diff --git a/packages/angular-cli/ember-cli/lib/utilities/open-editor.js b/packages/angular-cli/ember-cli/lib/utilities/open-editor.js deleted file mode 100644 index 11696ed02d4e..000000000000 --- a/packages/angular-cli/ember-cli/lib/utilities/open-editor.js +++ /dev/null @@ -1,42 +0,0 @@ -'use strict'; - -var Promise = require('../ext/promise'); -var spawn = require('child_process').spawn; - -function openEditor(file) { - if (!openEditor.canEdit()) { - throw new Error('EDITOR environment variable is not set'); - } - - if (!file) { - throw new Error('No `file` option provided'); - } - - var editorArgs = openEditor._env().EDITOR.split(' '); - var editor = editorArgs.shift(); - var editProcess = openEditor._spawn(editor, [file].concat(editorArgs), {stdio: 'inherit'}); - - return new Promise(function(resolve, reject) { - editProcess.on('close', function (code) { - if (code === 0) { - resolve(); - } else { - reject(); - } - }); - }); -} - -openEditor.canEdit = function() { - return openEditor._env().EDITOR !== undefined; -}; - -openEditor._env = function() { - return process.env; -}; - -openEditor._spawn = function() { - return spawn.apply(this, arguments); -}; - -module.exports = openEditor; diff --git a/packages/angular-cli/ember-cli/lib/utilities/parse-options.js b/packages/angular-cli/ember-cli/lib/utilities/parse-options.js deleted file mode 100644 index d2d06f9b0efe..000000000000 --- a/packages/angular-cli/ember-cli/lib/utilities/parse-options.js +++ /dev/null @@ -1,11 +0,0 @@ -'use strict'; - -var reduce = require('lodash/reduce'); - -module.exports = function parseOptions(args) { - return reduce(args, function(result, arg) { - var parts = arg.split(':'); - result[parts[0]] = parts.slice(1).join(':'); - return result; - }, {}); -}; diff --git a/packages/angular-cli/ember-cli/lib/utilities/path.js b/packages/angular-cli/ember-cli/lib/utilities/path.js deleted file mode 100644 index 39c686da4913..000000000000 --- a/packages/angular-cli/ember-cli/lib/utilities/path.js +++ /dev/null @@ -1,31 +0,0 @@ -'use strict'; - -module.exports = { - /** - Returns a relative parent path string using the path provided - - @method getRelativeParentPath - @param {String} path The path to relatively get to. - @return {String} the relative path string. - */ - getRelativeParentPath: function getRelativeParentPath(path, offset, slash) { - var offsetValue = offset || 0; - var trailingSlash = typeof slash === 'undefined' ? true : slash; - var outputPath = new Array(path.split('/').length + 1 - offsetValue).join('../'); - - return trailingSlash ? outputPath : outputPath.substr(0, outputPath.length - 1); - }, - - /** - Returns a relative path string using the path provided - - @method getRelativePath - @param {String} path The path to relatively get to. - @return {String} the relative path string. - */ - getRelativePath: function getRelativePath(path, offset) { - var offsetValue = offset || 0; - var relativePath = new Array(path.split('/').length - offsetValue).join('../'); - return relativePath || './'; - } -}; diff --git a/packages/angular-cli/ember-cli/lib/utilities/platform-checker.js b/packages/angular-cli/ember-cli/lib/utilities/platform-checker.js deleted file mode 100644 index e0a3dabe1099..000000000000 --- a/packages/angular-cli/ember-cli/lib/utilities/platform-checker.js +++ /dev/null @@ -1,35 +0,0 @@ -'use strict'; - -var semver = require('semver'); -var debug = require('debug')('ember-cli:platform-checker:'); - -var LOWER_RANGE = '0.12.0'; -var UPPER_RANGE = '6.0.0'; - -module.exports = PlatformChecker; -function PlatformChecker(version) { - this.version = version; - this.isValid = this.checkIsValid(); - this.isUntested = this.checkIsUntested(); - this.isDeprecated = this.checkIsDeprecated(); - - debug('%o', { - version: this.version, - isValid: this.isValid, - isUntested: this.isUntested, - isDeprecated: this.isDeprecated - }); -} - -PlatformChecker.prototype.checkIsValid = function() { - return semver.satisfies(this.version, '>=' + LOWER_RANGE + ' <' + UPPER_RANGE); -}; - -PlatformChecker.prototype.checkIsDeprecated = function() { - return semver.satisfies(this.version, '<' + LOWER_RANGE); -}; - -PlatformChecker.prototype.checkIsUntested = function() { - return semver.satisfies(this.version, '>=' + UPPER_RANGE); -}; - diff --git a/packages/angular-cli/ember-cli/lib/utilities/print-command.js b/packages/angular-cli/ember-cli/lib/utilities/print-command.js deleted file mode 100644 index 087b5c2e6f16..000000000000 --- a/packages/angular-cli/ember-cli/lib/utilities/print-command.js +++ /dev/null @@ -1,92 +0,0 @@ -'use strict'; - -var chalk = require('chalk'); -var EOL = require('os').EOL; - -module.exports = function(initialMargin, shouldDescriptionBeGrey) { - initialMargin = initialMargin || ''; - - var output = ''; - - var options = this.anonymousOptions; - - // ... - if (options.length) { - output += ' ' + chalk.yellow(options.map(function(option) { - // blueprints we insert brackets, commands already have them - if (option.indexOf('<') === 0) { - return option; - } else { - return '<' + option + '>'; - } - }).join(' ')); - } - - options = this.availableOptions; - - // - if (options.length) { - output += ' ' + chalk.cyan(''); - } - - // Description - var description = this.description; - if (description) { - if (shouldDescriptionBeGrey) { - description = chalk.grey(description); - } - output += EOL + initialMargin + ' ' + description; - } - - // aliases: a b c - if (this.aliases && this.aliases.length) { - output += EOL + initialMargin + ' ' + chalk.grey('aliases: ' + this.aliases.filter(function(a) { return a; }).join(', ')); - } - - // --available-option (Required) (Default: value) - // ... - options.forEach(function(option) { - output += EOL + initialMargin + ' ' + chalk.cyan('--' + option.name); - - if (option.values) { - output += chalk.cyan('=' + option.values.join('|')); - } - - if (option.type) { - var types = Array.isArray(option.type) ? - option.type.map(formatType).join(', ') : - formatType(option.type); - - output += ' ' + chalk.cyan('(' + types + ')'); - } - - if (option.required) { - output += ' ' + chalk.cyan('(Required)'); - } - - if (option.default !== undefined) { - output += ' ' + chalk.cyan('(Default: ' + option.default + ')'); - } - - if (option.description) { - output += ' ' + option.description; - } - - if (option.aliases && option.aliases.length) { - output += EOL + initialMargin + ' ' + chalk.grey('aliases: ' + option.aliases.map(function(a) { - if (typeof a === 'string') { - return (a.length > 4 ? '--' : '-') + a + (option.type === Boolean ? '' : ' '); - } else { - var key = Object.keys(a)[0]; - return (key.length > 4 ? '--' : '-') + key + ' (--' + option.name + '=' + a[key] + ')'; - } - }).join(', ')); - } - }); - - return output; -}; - -function formatType(type) { - return typeof type === 'string' ? type : type.name; -} diff --git a/packages/angular-cli/ember-cli/lib/utilities/printable-properties.js b/packages/angular-cli/ember-cli/lib/utilities/printable-properties.js deleted file mode 100644 index 21b6b5b63f07..000000000000 --- a/packages/angular-cli/ember-cli/lib/utilities/printable-properties.js +++ /dev/null @@ -1,36 +0,0 @@ -'use strict'; - -var commandProperties = [ - 'name', - 'description', - 'aliases', - 'works', - 'availableOptions', - 'anonymousOptions' -]; -var blueprintProperties = [ - 'name', - 'description', - 'availableOptions', - 'anonymousOptions', - 'overridden' -]; - -function forEachWithProperty(properties, forEach, context) { - return properties.filter(function(key) { - return this[key] !== undefined; - }, context).forEach(forEach, context); -} - -module.exports = { - command: { - forEachWithProperty: function(forEach, context) { - return forEachWithProperty(commandProperties, forEach, context); - } - }, - blueprint: { - forEachWithProperty: function(forEach, context) { - return forEachWithProperty(blueprintProperties, forEach, context); - } - } -}; diff --git a/packages/angular-cli/ember-cli/lib/utilities/require-as-hash.js b/packages/angular-cli/ember-cli/lib/utilities/require-as-hash.js deleted file mode 100644 index 843fe35a9e56..000000000000 --- a/packages/angular-cli/ember-cli/lib/utilities/require-as-hash.js +++ /dev/null @@ -1,31 +0,0 @@ -'use strict'; -var stringUtils = require('ember-cli-string-utils'); - -// Gathers subclasses of a certain specified base class into a hash. -// -// e.g.: -// Files: -// - ./hamster.js which exports an class of Hamster subclass of Rodent -// - ./parrot.js which exports an instance of Parrot (not a Rodent!) -// -// requireAsHash('./*.js', Rodent): -// { -// Hamster: Hamster // Same as require('./hamster.js') -// } - - -var globSync = require('glob').sync; -var path = require('path'); - -module.exports = requireAsHash; -function requireAsHash(pattern, type) { - return globSync(pattern) - .reduce(function(hash, file) { - - var klass = require(file); - if (!type || (klass.prototype instanceof type)) { - hash[stringUtils.classify(path.basename(file, '.js'))] = klass; - } - return hash; - }, {}); -} diff --git a/packages/angular-cli/ember-cli/lib/utilities/require-local.js b/packages/angular-cli/ember-cli/lib/utilities/require-local.js deleted file mode 100644 index 69ee177d4070..000000000000 --- a/packages/angular-cli/ember-cli/lib/utilities/require-local.js +++ /dev/null @@ -1,8 +0,0 @@ -'use strict'; - -var path = require('path'); -var nodeModulesPath = require('node-modules-path'); - -module.exports = function requireLocal(lib) { - return require(path.join(nodeModulesPath(process.cwd()), lib)); -}; diff --git a/packages/angular-cli/ember-cli/lib/utilities/sequence.js b/packages/angular-cli/ember-cli/lib/utilities/sequence.js deleted file mode 100644 index ec9e6eb7e7e8..000000000000 --- a/packages/angular-cli/ember-cli/lib/utilities/sequence.js +++ /dev/null @@ -1,41 +0,0 @@ -'use strict'; - -var Promise = require('../ext/promise'); -/* - * - * given an array of functions, that may or may not return promises sequence - * will invoke them sequentially. If a promise is encountered sequence will - * wait until it fulfills before moving to the next entry. - * - * ```js - * var tasks = [ - * function() { return Promise.resolve(1); }, - * 2, - * function() { return timeout(1000).then(function() { return 3; } }, - * ]; - * - * sequence(tasks).then(function(results) { - * results === [ - * 1, - * 2, - * 3 - * ] - * }); - * ``` - * - * @method sequence - * @param tasks - * @return Promise - * - */ -module.exports = function sequence(tasks) { - var length = tasks.length; - var current = Promise.resolve(); - var results = new Array(length); - - for (var i = 0; i < length; ++i) { - current = results[i] = current.then(tasks[i]); - } - - return Promise.all(results); -}; diff --git a/packages/angular-cli/ember-cli/lib/utilities/valid-project-name.js b/packages/angular-cli/ember-cli/lib/utilities/valid-project-name.js deleted file mode 100644 index 8d6833177bfb..000000000000 --- a/packages/angular-cli/ember-cli/lib/utilities/valid-project-name.js +++ /dev/null @@ -1,11 +0,0 @@ -'use strict'; - -module.exports = function(name) { - name = name.toLowerCase(); - - if (['test', 'ember', 'ember-cli', 'vendor', 'app'].indexOf(name) > -1) { return false; } - if (name.indexOf('.') > -1) { return false; } - if (!isNaN(parseInt(name.charAt(0), 10))) { return false; } - - return true; -}; diff --git a/packages/angular-cli/ember-cli/lib/utilities/version-utils.js b/packages/angular-cli/ember-cli/lib/utilities/version-utils.js deleted file mode 100644 index bad42d4737f5..000000000000 --- a/packages/angular-cli/ember-cli/lib/utilities/version-utils.js +++ /dev/null @@ -1,26 +0,0 @@ -'use strict'; - -var existsSync = require('exists-sync'); -var path = require('path'); -var getRepoInfo = require('git-repo-info'); - -module.exports = { - emberCLIVersion: function emberCLIVersion() { - var gitPath = path.join(__dirname, '..','..','.git'); - var output = [require('../../../package.json').version]; - - if (existsSync(gitPath)) { - var repoInfo = getRepoInfo(gitPath); - - output.push(repoInfo.branch); - output.push(repoInfo.abbreviatedSha); - } - - return output.join('-'); - }, - - isDevelopment: function isDevelopment(version) { - // match postfix SHA in dev version - return !!version.match(/\b[0-9a-f]{5,40}\b/); - } -}; diff --git a/packages/angular-cli/ember-cli/lib/utilities/windows-admin.js b/packages/angular-cli/ember-cli/lib/utilities/windows-admin.js deleted file mode 100644 index f5a6d69e50c6..000000000000 --- a/packages/angular-cli/ember-cli/lib/utilities/windows-admin.js +++ /dev/null @@ -1,41 +0,0 @@ -'use strict'; - -var Promise = require('../ext/promise'); -var chalk = require('chalk'); -var exec = require('child_process').exec; - -module.exports = { - /** - * Uses the eon-old command NET SESSION to determine whether or not the - * current user has elevated rights (think sudo, but Windows). - * - * @method checkWindowsElevation - * @param {Object} ui - ui object used to call writeLine(); - * @return {Object} Object describing whether we're on windows and if admin rights exist - */ - checkWindowsElevation: function (ui) { - return new Promise(function (resolve) { - if (/^win/.test(process.platform)) { - exec('NET SESSION', function (error, stdout, stderr) { - var elevated = (!stderr || stderr.length === 0); - - if (!elevated && ui && ui.writeLine) { - ui.writeLine(chalk.yellow('\nRunning without elevated rights. ' + - 'Running Ember CLI "as Administrator" increases performance significantly.')); - ui.writeLine('See ember-cli.com/user-guide/#windows for details.\n'); - } - - resolve({ - windows: true, - elevated: elevated - }); - }); - } else { - resolve({ - windows: false, - elevated: null - }); - } - }); - } -}; diff --git a/packages/angular-cli/lib/cli/index.js b/packages/angular-cli/lib/cli/index.js deleted file mode 100644 index 6b4550f8eb85..000000000000 --- a/packages/angular-cli/lib/cli/index.js +++ /dev/null @@ -1,39 +0,0 @@ -/*eslint-disable no-console */ - -// Prevent the dependency validation from tripping because we don't import zone.js. We need -// it as a peer dependency of @angular/core. -// require('zone.js') - - -// This file hooks up on require calls to transpile TypeScript. -const cli = require('../../ember-cli/lib/cli'); -const UI = require('../../ember-cli/lib/ui'); -const Watcher = require('../../ember-cli/lib/models/watcher'); -const path = require('path'); - -Error.stackTraceLimit = Infinity; - -module.exports = function(options) { - - // patch UI to not print Ember-CLI warnings (which don't apply to Angular-CLI) - UI.prototype.writeWarnLine = function () { } - - // patch Watcher to always default to node, not checking for Watchman - Watcher.detectWatcher = function(ui, _options){ - var options = _options || {}; - options.watcher = 'node'; - return Promise.resolve(options); - } - - options.cli = { - name: 'ng', - root: path.join(__dirname, '..', '..'), - npmPackage: 'angular-cli' - }; - - // ensure the environemnt variable for dynamic paths - process.env.PWD = process.env.PWD || process.cwd(); - process.env.CLI_ROOT = process.env.CLI_ROOT || path.resolve(__dirname, '..', '..'); - - return cli(options); -}; diff --git a/packages/angular-cli/lib/config/schema.d.ts b/packages/angular-cli/lib/config/schema.d.ts deleted file mode 100644 index 1f5ea0d0d526..000000000000 --- a/packages/angular-cli/lib/config/schema.d.ts +++ /dev/null @@ -1,78 +0,0 @@ -export interface CliConfig { - /** - * The global configuration of the project. - */ - project?: { - version?: string; - name?: string; - }; - /** - * Properties of the different applications in this project. - */ - apps?: { - root?: string; - outDir?: string; - assets?: string; - index?: string; - main?: string; - test?: string; - tsconfig?: string; - prefix?: string; - mobile?: boolean; - /** - * Global styles to be included in the build. - */ - styles?: string[]; - /** - * Global scripts to be included in the build. - */ - scripts?: string[]; - /** - * Name and corresponding file for environment config. - */ - environments?: { - [name: string]: any; - }; - }[]; - /** - * Configuration reserved for installed third party addons. - */ - addons?: { - [name: string]: any; - }[]; - /** - * Configuration reserved for installed third party packages. - */ - packages?: { - [name: string]: any; - }[]; - e2e?: { - protractor?: { - config?: string; - }; - }; - test?: { - karma?: { - config?: string; - }; - }; - defaults?: { - styleExt?: string; - prefixInterfaces?: boolean; - poll?: number; - viewEncapsulation?: string; - changeDetection?: string; - inline?: { - style?: boolean; - template?: boolean; - }; - spec?: { - class?: boolean; - component?: boolean; - directive?: boolean; - module?: boolean; - pipe?: boolean; - service?: boolean; - }; - }; -} diff --git a/packages/angular-cli/lib/config/schema.json b/packages/angular-cli/lib/config/schema.json deleted file mode 100644 index 2334acd76427..000000000000 --- a/packages/angular-cli/lib/config/schema.json +++ /dev/null @@ -1,232 +0,0 @@ -{ - "$comment": "Please run `npm run build-config-interface` after changing this file. Thanks!", - "$schema": "http://json-schema.org/draft-04/schema#", - "id": "CliConfig", - "title": "Angular CLI Config Schema", - "type": "object", - "properties": { - "project": { - "description": "The global configuration of the project.", - "type": "object", - "properties": { - "version": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "additionalProperties": false - }, - "apps": { - "description": "Properties of the different applications in this project.", - "type": "array", - "items": { - "type": "object", - "properties": { - "root": { - "type": "string" - }, - "outDir": { - "type": "string", - "default": "dist/" - }, - "assets": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ], - "default": [] - }, - "index": { - "type": "string", - "default": "index.html" - }, - "main": { - "type": "string" - }, - "test": { - "type": "string" - }, - "tsconfig": { - "type": "string" - }, - "prefix": { - "type": "string" - }, - "mobile": { - "type": "boolean" - }, - "styles": { - "description": "Global styles to be included in the build.", - "type": "array", - "items": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "input": { - "type": "string" - } - }, - "additionalProperties": true - } - ] - }, - "additionalProperties": false - }, - "scripts": { - "description": "Global scripts to be included in the build.", - "type": "array", - "items": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "input": { - "type": "string" - } - }, - "additionalProperties": true - } - ] - }, - "additionalProperties": false - }, - "environments": { - "description": "Name and corresponding file for environment config.", - "type": "object", - "additionalProperties": true - } - }, - "additionalProperties": false - }, - "additionalProperties": false - }, - "addons": { - "description": "Configuration reserved for installed third party addons.", - "type": "array", - "items": { - "type": "object", - "properties": {}, - "additionalProperties": true - } - }, - "packages": { - "description": "Configuration reserved for installed third party packages.", - "type": "array", - "items": { - "type": "object", - "properties": {}, - "additionalProperties": true - } - }, - "e2e": { - "type": "object", - "properties": { - "protractor": { - "type": "object", - "properties": { - "config": { - "type": "string" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "test": { - "type": "object", - "properties": { - "karma": { - "type": "object", - "properties": { - "config": { - "type": "string" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "defaults": { - "type": "object", - "properties": { - "styleExt": { - "type": "string" - }, - "prefixInterfaces": { - "type": "boolean" - }, - "poll": { - "type": "number" - }, - "viewEncapsulation": { - "type": "string" - }, - "changeDetection": { - "type": "string" - }, - "inline": { - "type": "object", - "properties": { - "style": { - "type": "boolean", - "default": false - }, - "template": { - "type": "boolean", - "default": false - } - } - }, - "spec": { - "type": "object", - "properties": { - "class": { - "type": "boolean", - "default": false - }, - "component": { - "type": "boolean", - "default": true - }, - "directive": { - "type": "boolean", - "default": true - }, - "module": { - "type": "boolean", - "default": false - }, - "pipe": { - "type": "boolean", - "default": true - }, - "service": { - "type": "boolean", - "default": true - } - } - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false -} diff --git a/packages/angular-cli/lib/webpack/compression-plugin.ts b/packages/angular-cli/lib/webpack/compression-plugin.ts deleted file mode 100644 index 32c627f17f58..000000000000 --- a/packages/angular-cli/lib/webpack/compression-plugin.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** Forked from https://github.com/webpack/compression-webpack-plugin. */ -const async = require('async'); -const url = require('url'); - -const RawSource = require('webpack-sources/lib/RawSource'); - - -export interface CompressionPluginOptions { - algorithm?: string; - asset?: string; - level?: number; - flush?: boolean; - chunkSize?: number; - test?: RegExp | RegExp[]; - windowBits?: number; - memLevel?: number; - strategy?: number; - dictionary?: any; - threshold?: number; - minRatio?: number; -} - - -export class CompressionPlugin { - private asset = '[path].gz[query]'; - private algorithm: Function; - private compressionOptions: any = {}; - private test: RegExp[]; - private threshold: number = 0; - private minRatio: number = 0.8; - - constructor(options: CompressionPluginOptions = {}) { - if (options.hasOwnProperty('asset')) { - this.asset = options.asset; - } - - const algorithm = options.hasOwnProperty('algorithm') ? options.algorithm : 'gzip'; - - const zlib = require('zlib'); - - this.compressionOptions = {}; - this.algorithm = zlib[algorithm]; - if (!this.algorithm) { - throw new Error(`Algorithm not found in zlib: "${algorithm}".`); - } - - this.compressionOptions = { - level: options.level || 9, - flush: options.flush, - chunkSize: options.chunkSize, - windowBits: options.windowBits, - memLevel: options.memLevel, - strategy: options.strategy, - dictionary: options.dictionary - }; - - if (options.hasOwnProperty('test')) { - if (Array.isArray(options.test)) { - this.test = options.test as RegExp[]; - } else { - this.test = [options.test as RegExp]; - } - } - if (options.hasOwnProperty('threshold')) { - this.threshold = options.threshold; - } - if (options.hasOwnProperty('minRatio')) { - this.minRatio = options.minRatio; - } - } - - apply(compiler: any) { - compiler.plugin('this-compilation', (compilation: any) => { - compilation.plugin('optimize-assets', (assets: any, callback: Function) => { - async.forEach(Object.keys(assets), (file: string, callback: Function) => { - if (this.test.every((t) => !t.test(file))) { - return callback(); - } - - const asset = assets[file]; - let content = asset.source(); - if (!Buffer.isBuffer(content)) { - content = new Buffer(content, 'utf-8'); - } - - const originalSize = content.length; - if (originalSize < this.threshold) { - return callback(); - } - - this.algorithm(content, this.compressionOptions, (err: Error, result: string) => { - if (err) { - return callback(err); - } - if (result.length / originalSize > this.minRatio) { - return callback(); - } - - const parse = url.parse(file); - const newFile = this.asset - .replace(/\[file]/g, file) - .replace(/\[path]/g, parse.pathname) - .replace(/\[query]/g, parse.query || ''); - - assets[newFile] = new RawSource(result); - callback(); - }); - }, callback); - }); - }); - } -} diff --git a/packages/angular-cli/models/config.ts b/packages/angular-cli/models/config.ts deleted file mode 100644 index f98e113f070a..000000000000 --- a/packages/angular-cli/models/config.ts +++ /dev/null @@ -1,68 +0,0 @@ -import {CliConfig as CliConfigBase} from './config/config'; -import {CliConfig as ConfigInterface} from '../lib/config/schema'; -import { oneLine } from 'common-tags'; -import * as chalk from 'chalk'; -import * as fs from 'fs'; -import * as path from 'path'; - -export const CLI_CONFIG_FILE_NAME = 'angular-cli.json'; - - -function _findUp(name: string, from: string) { - let currentDir = from; - while (currentDir && currentDir !== path.parse(currentDir).root) { - const p = path.join(currentDir, name); - if (fs.existsSync(p)) { - return p; - } - - currentDir = path.dirname(currentDir); - } - - return null; -} - - -function getUserHome() { - return process.env[(process.platform == 'win32') ? 'USERPROFILE' : 'HOME']; -} - - -export class CliConfig extends CliConfigBase { - static configFilePath(projectPath?: string): string { - // Find the configuration, either where specified, in the angular-cli project - // (if it's in node_modules) or from the current process. - return (projectPath && _findUp(CLI_CONFIG_FILE_NAME, projectPath)) - || _findUp(CLI_CONFIG_FILE_NAME, process.cwd()) - || _findUp(CLI_CONFIG_FILE_NAME, __dirname); - } - - - static fromProject(): CliConfig { - const configPath = this.configFilePath(); - const globalConfigPath = path.join(getUserHome(), CLI_CONFIG_FILE_NAME); - - if (!configPath) { - return CliConfigBase.fromJson({}); - } - - const cliConfig = CliConfigBase.fromConfigPath( - CliConfig.configFilePath(), [globalConfigPath]); - - const aliases = [ - cliConfig.alias('apps.0.root', 'defaults.sourceDir'), - cliConfig.alias('apps.0.prefix', 'defaults.prefix') - ]; - - // If any of them returned true, output a deprecation warning. - if (aliases.some(x => !!x)) { - console.error(chalk.yellow(oneLine` - The "defaults.prefix" and "defaults.sourceDir" properties of angular-cli.json - are deprecated in favor of "apps[0].root" and "apps[0].prefix".\n - Please update in order to avoid errors in future versions of angular-cli. - `)); - } - - return cliConfig as CliConfig; - } -} diff --git a/packages/angular-cli/models/config/config.ts b/packages/angular-cli/models/config/config.ts deleted file mode 100644 index 4968747f7cba..000000000000 --- a/packages/angular-cli/models/config/config.ts +++ /dev/null @@ -1,102 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -import {SchemaClass, SchemaClassFactory} from '../json-schema/schema-class-factory'; - - -const DEFAULT_CONFIG_SCHEMA_PATH = path.join(__dirname, '../../lib/config/schema.json'); - -class InvalidConfigError extends Error { - constructor(message: string) { - super(message); - this.message = message; - this.name = 'InvalidConfigError'; - } -} - - -export class CliConfig { - private _config: SchemaClass; - - constructor(private _configPath: string, - schema: Object, - configJson: JsonType, - fallbacks: JsonType[] = []) { - this._config = new (SchemaClassFactory(schema))(configJson, ...fallbacks); - } - - get config(): JsonType { return this._config; } - - save(path: string = this._configPath) { - return fs.writeFileSync(path, this.serialize(), 'utf-8'); - } - serialize(mimetype = 'application/json'): string { - return this._config.$$serialize(mimetype); - } - - alias(path: string, newPath: string): boolean { - return this._config.$$alias(path, newPath); - } - - get(jsonPath: string) { - return this._config.$$get(jsonPath); - } - - typeOf(jsonPath: string): string { - return this._config.$$typeOf(jsonPath); - } - isDefined(jsonPath: string): boolean { - return this._config.$$defined(jsonPath); - } - deletePath(jsonPath: string) { - return this._config.$$delete(jsonPath); - } - - set(jsonPath: string, value: any) { - this._config.$$set(jsonPath, value); - } - - static fromJson(content: ConfigType, ...global: ConfigType[]) { - const schemaContent = fs.readFileSync(DEFAULT_CONFIG_SCHEMA_PATH, 'utf-8'); - let schema: Object; - try { - schema = JSON.parse(schemaContent); - } catch (err) { - throw new InvalidConfigError(err.message); - } - - return new CliConfig(null, schema, content, global); - } - - static fromConfigPath(configPath: string, otherPath: string[] = []): CliConfig { - const configContent = fs.readFileSync(configPath, 'utf-8'); - const schemaContent = fs.readFileSync(DEFAULT_CONFIG_SCHEMA_PATH, 'utf-8'); - const otherContents = otherPath - .map(path => fs.existsSync(path) && fs.readFileSync(path, 'utf-8')) - .filter(content => !!content); - - let content: T; - let schema: Object; - let others: T[]; - - try { - content = JSON.parse(configContent); - } catch (err) { - throw new InvalidConfigError( - 'Parsing angular-cli.json failed. Please make sure your angular-cli.json' - + ' is valid JSON. Error:\n' + err - ); - } - - try { - schema = JSON.parse(schemaContent); - others = otherContents.map(otherContent => JSON.parse(otherContent)); - } catch (err) { - throw new InvalidConfigError( - `Parsing Angular CLI schema or other configuration files failed. Error:\n${err}` - ); - } - - return new CliConfig(configPath, schema, content, others); - } -} diff --git a/packages/angular-cli/models/error.ts b/packages/angular-cli/models/error.ts deleted file mode 100644 index 4dc5e590012f..000000000000 --- a/packages/angular-cli/models/error.ts +++ /dev/null @@ -1,11 +0,0 @@ -export class NgToolkitError extends Error { - constructor(message?: string) { - super(); - - if (message) { - this.message = message; - } else { - this.message = (this.constructor).name; - } - } -} diff --git a/packages/angular-cli/models/index.ts b/packages/angular-cli/models/index.ts deleted file mode 100644 index b4205cf32e91..000000000000 --- a/packages/angular-cli/models/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './webpack-build-common'; -export * from './webpack-build-production'; -export * from './webpack-build-development'; -export * from './webpack-build-mobile'; -export * from './webpack-build-utils'; diff --git a/packages/angular-cli/models/json-schema/schema-class-factory.ts b/packages/angular-cli/models/json-schema/schema-class-factory.ts deleted file mode 100644 index 9af3b537031d..000000000000 --- a/packages/angular-cli/models/json-schema/schema-class-factory.ts +++ /dev/null @@ -1,189 +0,0 @@ -import {NgToolkitError} from '../error'; -import {Serializer} from './serializer'; -import {RootSchemaTreeNode, SchemaTreeNode} from './schema-tree'; - -export class InvalidJsonPath extends NgToolkitError {} - - -// The schema tree node property of the SchemaClass. -const kSchemaNode = Symbol('schema-node'); -// The value property of the SchemaClass. -const kOriginalRoot = Symbol('schema-value'); - - -/** - * Splits a JSON path string into fragments. Fragments can be used to get the value referenced - * by the path. For example, a path of "a[3].foo.bar[2]" would give you a fragment array of - * ["a", 3, "foo", "bar", 2]. - * @param path The JSON string to parse. - * @returns {string[]} The fragments for the string. - * @private - */ -function _parseJsonPath(path: string): string[] { - const fragments = (path || '').split(/\./g); - const result: string[] = []; - - while (fragments.length > 0) { - const fragment = fragments.shift(); - - const match = fragment.match(/([^\[]+)((\[.*\])*)/); - if (!match) { - throw new InvalidJsonPath(); - } - - result.push(match[1]); - if (match[2]) { - const indices = match[2].slice(1, -1).split(']['); - result.push(...indices); - } - } - - return result.filter(fragment => !!fragment); -} - - -/** Get a SchemaTreeNode from the JSON path string. */ -function _getSchemaNodeForPath(rootMetaData: SchemaTreeNode, - path: string): SchemaTreeNode { - let fragments = _parseJsonPath(path); - // TODO: make this work with union (oneOf) schemas - return fragments.reduce((md: SchemaTreeNode, current: string) => { - return md && md.children && md.children[current]; - }, rootMetaData); -} - - -/** The interface the SchemaClassFactory returned class implements. */ -export interface SchemaClass extends Object { - $$root(): JsonType; - $$get(path: string): any; - $$set(path: string, value: any): void; - $$alias(source: string, destination: string): boolean; - $$dispose(): void; - - // Metadata of the schema. - $$typeOf(path: string): string; - $$defined(path: string): boolean; - $$delete(path: string): void; - - $$serialize(mimetype?: string): string; -} - - -class SchemaClassBase implements SchemaClass { - constructor(schema: Object, value: T, ...fallbacks: T[]) { - (this as any)[kOriginalRoot] = value; - const forward = fallbacks.length > 0 - ? (new SchemaClassBase(schema, fallbacks.pop(), ...fallbacks).$$schema()) - : null; - (this as any)[kSchemaNode] = new RootSchemaTreeNode(this, { - forward, - value, - schema - }); - } - - $$root(): T { return this as any; } - $$schema(): RootSchemaTreeNode { return (this as any)[kSchemaNode] as RootSchemaTreeNode; } - $$originalRoot(): T { return (this as any)[kOriginalRoot] as T; } - - /** Sets the value of a destination if the value is currently undefined. */ - $$alias(source: string, destination: string) { - let sourceSchemaTreeNode = _getSchemaNodeForPath(this.$$schema(), source); - - const fragments = _parseJsonPath(destination); - const maybeValue = fragments.reduce((value: any, current: string) => { - return value && value[current]; - }, this.$$originalRoot()); - - if (maybeValue !== undefined) { - sourceSchemaTreeNode.set(maybeValue); - return true; - } - return false; - } - - /** Destroy all links between schemas to allow for GC. */ - $$dispose() { - this.$$schema().dispose(); - } - - /** Get a value from a JSON path. */ - $$get(path: string): any { - const node = _getSchemaNodeForPath(this.$$schema(), path); - return node ? node.get() : undefined; - } - - /** Set a value from a JSON path. */ - $$set(path: string, value: any): void { - const node = _getSchemaNodeForPath(this.$$schema(), path); - if (node) { - node.set(value); - } else { - // This might be inside an object that can have additionalProperties, so - // a TreeNode would not exist. - const splitPath = _parseJsonPath(path); - if (!splitPath) { - return undefined; - } - const parent: any = splitPath - .slice(0, -1) - .reduce((parent: any, curr: string) => parent && parent[curr], this); - - if (parent) { - parent[splitPath[splitPath.length - 1]] = value; - } - } - } - - /** Get the Schema associated with a path. */ - $$typeOf(path: string): string { - const node = _getSchemaNodeForPath(this.$$schema(), path); - return node ? node.type : null; - } - - $$defined(path: string): boolean { - const node = _getSchemaNodeForPath(this.$$schema(), path); - return node ? node.defined : false; - } - - $$delete(path: string) { - const node = _getSchemaNodeForPath(this.$$schema(), path); - if (node) { - node.destroy(); - } - } - - /** Serialize into a string. */ - $$serialize(mimetype = 'application/json', ...options: any[]): string { - let str = ''; - const serializer = Serializer.fromMimetype(mimetype, (s) => str += s, ...options); - - serializer.start(); - this.$$schema().serialize(serializer); - serializer.end(); - - return str; - } -} -export interface SchemaClassFactoryReturn { - new (value: T, ...fallbacks: T[]): SchemaClass; -} - -/** - * Create a class from a JSON SCHEMA object. Instanciating that class with an object - * allows for extended behaviour. - * This is the base API to access the Configuration in the CLI. - * @param schema - * @returns {GeneratedSchemaClass} - * @constructor - */ -export function SchemaClassFactory(schema: Object): SchemaClassFactoryReturn { - class GeneratedSchemaClass extends SchemaClassBase { - constructor(value: T, ...fallbacks: T[]) { - super(schema, value, ...fallbacks); - } - } - - return GeneratedSchemaClass; -} diff --git a/packages/angular-cli/models/json-schema/schema-tree.ts b/packages/angular-cli/models/json-schema/schema-tree.ts deleted file mode 100644 index 2dd120a22788..000000000000 --- a/packages/angular-cli/models/json-schema/schema-tree.ts +++ /dev/null @@ -1,371 +0,0 @@ -import {NgToolkitError} from '../error'; - -import {Serializer} from './serializer'; - - -export class InvalidSchema extends NgToolkitError {} -export class MissingImplementationError extends NgToolkitError {} -export class SettingReadOnlyPropertyError extends NgToolkitError {} - - -export interface Schema { - [key: string]: any; -} - - -/** This interface is defined to simplify the arguments passed in to the SchemaTreeNode. */ -export type TreeNodeConstructorArgument = { - parent?: SchemaTreeNode; - name?: string; - value: T; - forward: SchemaTreeNode; - schema: Schema; -}; - - -/** - * Holds all the information, including the value, of a node in the schema tree. - */ -export abstract class SchemaTreeNode { - // Hierarchy objects - protected _parent: SchemaTreeNode; - - protected _defined: boolean = false; - protected _dirty: boolean = false; - - protected _schema: Schema; - protected _name: string; - - protected _value: T; - protected _forward: SchemaTreeNode; - - constructor(nodeMetaData: TreeNodeConstructorArgument) { - this._schema = nodeMetaData.schema; - this._name = nodeMetaData.name; - this._value = nodeMetaData.value; - this._forward = nodeMetaData.forward; - this._parent = nodeMetaData.parent; - } - dispose() { - this._parent = null; - this._schema = null; - this._value = null; - - this._forward.dispose(); - this._forward = null; - } - - get defined() { return this._defined; } - get dirty() { return this._dirty; } - set dirty(v: boolean) { - if (v) { - this._defined = true; - this._dirty = true; - if (this._parent) { - this._parent.dirty = true; - } - } - } - - abstract get type(): string; - abstract destroy(): void; - get name() { return this._name; } - get readOnly(): boolean { return this._schema['readOnly']; } - get parent(): SchemaTreeNode { return this._parent; } - get children(): { [key: string]: SchemaTreeNode} { return null; } - - abstract get(): T; - set(v: T) { - if (!this.readOnly) { - throw new MissingImplementationError(); - } - throw new SettingReadOnlyPropertyError(); - }; - - abstract serialize(serializer: Serializer, value?: T): void; - - protected static _defineProperty(proto: any, treeNode: SchemaTreeNode): void { - if (treeNode.readOnly) { - Object.defineProperty(proto, treeNode.name, { - enumerable: true, - get: () => treeNode.get() - }); - } else { - Object.defineProperty(proto, treeNode.name, { - enumerable: true, - get: () => treeNode.get(), - set: (v: T) => treeNode.set(v) - }); - } - } -} - - -/** Base Class used for Non-Leaves TreeNode. Meaning they can have children. */ -export abstract class NonLeafSchemaTreeNode extends SchemaTreeNode { - dispose() { - for (const key of Object.keys(this.children)) { - this.children[key].dispose(); - } - super.dispose(); - } - - // Non leaves are read-only. - get readOnly() { return true; } - get() { - if (this.defined) { - return this._value; - } else { - return undefined; - } - } - - destroy() { - this._defined = false; - this._value = null; - } - - // Helper function to create a child based on its schema. - protected _createChildProperty(name: string, value: T, forward: SchemaTreeNode, - schema: Schema, define = true): SchemaTreeNode { - - let type: string; - - if (!schema['oneOf']) { - type = schema['type']; - } else { - for (let testSchema of schema['oneOf']) { - if ((testSchema['type'] === 'array' && Array.isArray(value)) - || typeof value === testSchema['type']) { - type = testSchema['type']; - schema = testSchema; - break; - } - } - } - - let Klass: any = null; - - switch (type) { - case 'object': Klass = ObjectSchemaTreeNode; break; - case 'array': Klass = ArraySchemaTreeNode; break; - case 'string': Klass = StringSchemaTreeNode; break; - case 'boolean': Klass = BooleanSchemaTreeNode; break; - case 'number': Klass = NumberSchemaTreeNode; break; - case 'integer': Klass = IntegerSchemaTreeNode; break; - - default: - console.error('Type ' + type + ' not understood by SchemaClassFactory.'); - return null; - } - - const metaData = new Klass({ parent: this, forward, value, schema, name }); - if (define) { - SchemaTreeNode._defineProperty(this._value, metaData); - } - return metaData; - } -} - - -/** A Schema Tree Node that represents an object. */ -export class ObjectSchemaTreeNode extends NonLeafSchemaTreeNode<{[key: string]: any}> { - // The map of all children metadata. - protected _children: { [key: string]: SchemaTreeNode }; - - constructor(metaData: TreeNodeConstructorArgument) { - super(metaData); - - let { value, forward, schema } = metaData; - if (value) { - this._defined = true; - } - this._children = Object.create(null); - this._value = Object.create(null); - - if (schema['properties']) { - for (const name of Object.keys(schema['properties'])) { - const propertySchema = schema['properties'][name]; - this._children[name] = this._createChildProperty( - name, - value ? value[name] : null, - forward ? (forward as ObjectSchemaTreeNode).children[name] : null, - propertySchema); - } - } else if (!schema['additionalProperties']) { - throw new InvalidSchema(); - } - - if (!schema['additionalProperties']) { - Object.freeze(this._value); - } else if (value) { - // Set other properties which don't have a schema. - for (const key of Object.keys(value)) { - if (!this._children[key]) { - this._value[key] = value[key]; - } - } - } - - Object.freeze(this._children); - } - - serialize(serializer: Serializer, value = this._value) { - serializer.object(() => { - for (const key of Object.keys(value)) { - if (this._children[key]) { - if (this._children[key].defined) { - serializer.property(key, () => this._children[key].serialize(serializer, value[key])); - } - } else if (this._schema['additionalProperties']) { - // Fallback to direct value output for additional properties - serializer.property(key, () => serializer.outputValue(value[key])); - } - } - }); - } - - get children() { return this._children; } - get type() { return 'object'; } -} - - -/** A Schema Tree Node that represents an array. */ -export class ArraySchemaTreeNode extends NonLeafSchemaTreeNode> { - // The map of all items metadata. - protected _items: SchemaTreeNode[]; - - constructor(metaData: TreeNodeConstructorArgument>) { - super(metaData); - - let { value, forward, schema } = metaData; - if (value) { - this._defined = true; - } else { - value = []; - } - this._items = []; - this._value = []; - - for (let index = 0; index < value.length; index++) { - this._items[index] = this._createChildProperty( - '' + index, - value && value[index], - forward && (forward as ArraySchemaTreeNode).children[index], - schema['items'] - ); - } - - if (!schema['additionalProperties']) { - Object.freeze(this._value); - } - } - - get children() { return this._items as {[key: string]: any}; } - get type() { return 'array'; } - - serialize(serializer: Serializer, value = this._value) { - serializer.array(() => { - for (let i = 0; i < value.length; i++) { - this._items[i].serialize(serializer, value[i]); - } - }); - } -} - - -/** - * The root class of the tree node. Receives a prototype that will be filled with the - * properties of the Schema root. - */ -export class RootSchemaTreeNode extends ObjectSchemaTreeNode { - constructor(proto: any, metaData: TreeNodeConstructorArgument) { - super(metaData); - - for (const key of Object.keys(this._children)) { - if (this._children[key]) { - SchemaTreeNode._defineProperty(proto, this._children[key]); - } - } - } -} - - -/** A leaf in the schema tree. Must contain a single primitive value. */ -export abstract class LeafSchemaTreeNode extends SchemaTreeNode { - private _default: T; - - constructor(metaData: TreeNodeConstructorArgument) { - super(metaData); - this._defined = !(metaData.value === undefined || metaData.value === null); - if ('default' in metaData.schema) { - this._default = metaData.schema['default']; - } - } - - get() { - if (!this.defined && this._forward) { - return this._forward.get(); - } - if (!this.defined && this._default !== undefined) { - return this._default; - } - return this._value === undefined ? undefined : this.convert(this._value); - } - set(v: T) { this.dirty = true; this._value = this.convert(v); } - - destroy() { - this._defined = false; - this._value = null; - } - - abstract convert(v: any): T; - - serialize(serializer: Serializer, value: T = this.get()) { - if (this.defined) { - serializer.outputValue(value); - } - } -} - - -/** Basic primitives for JSON Schema. */ -class StringSchemaTreeNode extends LeafSchemaTreeNode { - serialize(serializer: Serializer, value: string = this.get()) { - if (this.defined) { - serializer.outputString(value); - } - } - - convert(v: any) { return v === undefined ? undefined : '' + v; } - get type() { return 'string'; } -} - - -class BooleanSchemaTreeNode extends LeafSchemaTreeNode { - serialize(serializer: Serializer, value: boolean = this.get()) { - if (this.defined) { - serializer.outputBoolean(value); - } - } - - convert(v: any) { return v === undefined ? undefined : !!v; } - get type() { return 'boolean'; } -} - - -class NumberSchemaTreeNode extends LeafSchemaTreeNode { - serialize(serializer: Serializer, value: number = this.get()) { - if (this.defined) { - serializer.outputNumber(value); - } - } - - convert(v: any) { return v === undefined ? undefined : +v; } - get type() { return 'number'; } -} - - -class IntegerSchemaTreeNode extends NumberSchemaTreeNode { - convert(v: any) { return v === undefined ? undefined : Math.floor(+v); } -} diff --git a/packages/angular-cli/models/json-schema/serializer.ts b/packages/angular-cli/models/json-schema/serializer.ts deleted file mode 100644 index bee8c9645cf3..000000000000 --- a/packages/angular-cli/models/json-schema/serializer.ts +++ /dev/null @@ -1,150 +0,0 @@ -import {NgToolkitError} from '../error'; -export class InvalidStateError extends NgToolkitError {} -export class UnknownMimetype extends NgToolkitError {} - - -export interface WriterFn { - (str: string): void; -} - -export abstract class Serializer { - abstract start(): void; - abstract end(): void; - - abstract object(callback: () => void): void; - abstract property(name: string, callback: () => void): void; - abstract array(callback: () => void): void; - - abstract outputString(value: string): void; - abstract outputNumber(value: number): void; - abstract outputBoolean(value: boolean): void; - - // Fallback when the value does not have metadata. - abstract outputValue(value: any): void; - - - static fromMimetype(mimetype: string, writer: WriterFn, ...opts: any[]): Serializer { - let Klass: { new(writer: WriterFn, ...args: any[]): Serializer } = null; - switch (mimetype) { - case 'application/json': Klass = JsonSerializer; break; - - default: throw new UnknownMimetype(); - } - - return new Klass(writer, ...opts); - } -} - - -interface JsonSerializerState { - empty?: boolean; - type?: string; - property?: boolean; -} - -class JsonSerializer implements Serializer { - private _state: JsonSerializerState[] = []; - - constructor(private _writer: WriterFn, private _indentDelta = 2) {} - - private _willOutputValue() { - if (this._state.length > 0) { - const top = this._top(); - - const wasEmpty = top.empty; - top.empty = false; - - if (!wasEmpty && !top.property) { - this._writer(','); - } - if (!top.property) { - this._indent(); - } - } - } - - private _top(): JsonSerializerState { - return this._state[this._state.length - 1] || {}; - } - - private _indent(): string { - if (this._indentDelta == 0) { - return; - } - - let str = '\n'; - let i = this._state.length * this._indentDelta; - while (i--) { - str += ' '; - } - this._writer(str); - } - - start() {} - end() { - if (this._indentDelta) { - this._writer('\n'); - } - } - - object(callback: () => void) { - this._willOutputValue(); - - this._writer('{'); - - this._state.push({ empty: true, type: 'object' }); - callback(); - this._state.pop(); - - if (!this._top().empty) { - this._indent(); - } - this._writer('}'); - } - - property(name: string, callback: () => void) { - this._willOutputValue(); - - this._writer(JSON.stringify(name)); - this._writer(': '); - this._top().property = true; - callback(); - this._top().property = false; - } - - array(callback: () => void) { - this._willOutputValue(); - - this._writer('['); - this._state.push({ empty: true, type: 'array' }); - callback(); - this._state.pop(); - - if (!this._top().empty) { - this._indent(); - } - this._writer(']'); - } - - outputValue(value: any) { - this._willOutputValue(); - this._writer(JSON.stringify(value, null, this._indentDelta)); - } - - outputString(value: string) { - this._willOutputValue(); - this._writer(JSON.stringify(value)); - } - outputNumber(value: number) { - this._willOutputValue(); - this._writer(JSON.stringify(value)); - } - outputInteger(value: number) { - this._willOutputValue(); - this._writer(JSON.stringify(value)); - } - outputBoolean(value: boolean) { - this._willOutputValue(); - this._writer(JSON.stringify(value)); - } -} diff --git a/packages/angular-cli/models/webpack-build-common.ts b/packages/angular-cli/models/webpack-build-common.ts deleted file mode 100644 index d44226a798bf..000000000000 --- a/packages/angular-cli/models/webpack-build-common.ts +++ /dev/null @@ -1,186 +0,0 @@ -import * as webpack from 'webpack'; -import * as path from 'path'; -import { GlobCopyWebpackPlugin } from '../plugins/glob-copy-webpack-plugin'; -import { SuppressEntryChunksWebpackPlugin } from '../plugins/suppress-entry-chunks-webpack-plugin'; -import { packageChunkSort } from '../utilities/package-chunk-sort'; -import { BaseHrefWebpackPlugin } from '@angular-cli/base-href-webpack'; -import { extraEntryParser, makeCssLoaders } from './webpack-build-utils'; - -const autoprefixer = require('autoprefixer'); -const ProgressPlugin = require('webpack/lib/ProgressPlugin'); -const HtmlWebpackPlugin = require('html-webpack-plugin'); -const SilentError = require('silent-error'); - -/** - * Enumerate loaders and their dependencies from this file to let the dependency validator - * know they are used. - * - * require('source-map-loader') - * require('raw-loader') - * require('script-loader') - * require('json-loader') - * require('url-loader') - * require('file-loader') - */ - -export function getWebpackCommonConfig( - projectRoot: string, - environment: string, - appConfig: any, - baseHref: string, - sourcemap: boolean, - vendorChunk: boolean, - verbose: boolean, - progress: boolean -) { - - const appRoot = path.resolve(projectRoot, appConfig.root); - const appMain = path.resolve(appRoot, appConfig.main); - const nodeModules = path.resolve(projectRoot, 'node_modules'); - - let extraPlugins: any[] = []; - let extraRules: any[] = []; - let lazyChunks: string[] = []; - - let entryPoints: { [key: string]: string[] } = { - main: [appMain] - }; - - if (!(environment in appConfig.environments)) { - throw new SilentError(`Environment "${environment}" does not exist.`); - } - - // process global scripts - if (appConfig.scripts.length > 0) { - const globalScrips = extraEntryParser(appConfig.scripts, appRoot, 'scripts'); - - // add entry points and lazy chunks - globalScrips.forEach(script => { - if (script.lazy) { lazyChunks.push(script.entry); } - entryPoints[script.entry] = (entryPoints[script.entry] || []).concat(script.path); - }); - - // load global scripts using script-loader - extraRules.push({ - include: globalScrips.map((script) => script.path), test: /\.js$/, loader: 'script-loader' - }); - } - - // process global styles - if (appConfig.styles.length === 0) { - // create css loaders for component css - extraRules.push(...makeCssLoaders()); - } else { - const globalStyles = extraEntryParser(appConfig.styles, appRoot, 'styles'); - let extractedCssEntryPoints: string[] = []; - // add entry points and lazy chunks - globalStyles.forEach(style => { - if (style.lazy) { lazyChunks.push(style.entry); } - if (!entryPoints[style.entry]) { - // since this entry point doesn't exist yet, it's going to only have - // extracted css and we can supress the entry point - extractedCssEntryPoints.push(style.entry); - entryPoints[style.entry] = (entryPoints[style.entry] || []).concat(style.path); - } else { - // existing entry point, just push the css in - entryPoints[style.entry].push(style.path); - } - }); - - // create css loaders for component css and for global css - extraRules.push(...makeCssLoaders(globalStyles.map((style) => style.path))); - - if (extractedCssEntryPoints.length > 0) { - // don't emit the .js entry point for extracted styles - extraPlugins.push(new SuppressEntryChunksWebpackPlugin({ chunks: extractedCssEntryPoints })); - } - } - - if (vendorChunk) { - extraPlugins.push(new webpack.optimize.CommonsChunkPlugin({ - name: 'vendor', - chunks: ['main'], - minChunks: (module: any) => module.userRequest && module.userRequest.startsWith(nodeModules) - })); - } - - if (progress) { extraPlugins.push(new ProgressPlugin({ profile: verbose, colors: true })); } - - return { - devtool: sourcemap ? 'source-map' : false, - resolve: { - extensions: ['.ts', '.js'], - modules: [nodeModules], - }, - resolveLoader: { - modules: [nodeModules] - }, - context: projectRoot, - entry: entryPoints, - output: { - path: path.resolve(projectRoot, appConfig.outDir) - }, - module: { - rules: [ - { enforce: 'pre', test: /\.js$/, loader: 'source-map-loader', exclude: [nodeModules] }, - - { test: /\.json$/, loader: 'json-loader' }, - { test: /\.(jpg|png|gif)$/, loader: 'url-loader?limit=10000' }, - { test: /\.html$/, loader: 'raw-loader' }, - - { test: /\.(otf|ttf|woff|woff2)$/, loader: 'url-loader?limit=10000' }, - { test: /\.(eot|svg)$/, loader: 'file-loader' } - ].concat(extraRules) - }, - plugins: [ - new HtmlWebpackPlugin({ - template: path.resolve(appRoot, appConfig.index), - filename: path.resolve(appConfig.outDir, appConfig.index), - chunksSortMode: packageChunkSort(['inline', 'styles', 'scripts', 'vendor', 'main']), - excludeChunks: lazyChunks - }), - new BaseHrefWebpackPlugin({ - baseHref: baseHref - }), - new webpack.NormalModuleReplacementPlugin( - // This plugin is responsible for swapping the environment files. - // Since it takes a RegExp as first parameter, we need to escape the path. - // See https://webpack.github.io/docs/list-of-plugins.html#normalmodulereplacementplugin - new RegExp(path.resolve(appRoot, appConfig.environments['source']) - .replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&')), - path.resolve(appRoot, appConfig.environments[environment]) - ), - new webpack.optimize.CommonsChunkPlugin({ - minChunks: Infinity, - name: 'inline' - }), - new GlobCopyWebpackPlugin({ - patterns: appConfig.assets, - globOptions: { cwd: appRoot, dot: true, ignore: '**/.gitkeep' } - }), - new webpack.LoaderOptionsPlugin({ - test: /\.(css|scss|sass|less|styl)$/, - options: { - postcss: [autoprefixer()], - cssLoader: { sourceMap: sourcemap }, - sassLoader: { sourceMap: sourcemap }, - lessLoader: { sourceMap: sourcemap }, - stylusLoader: { sourceMap: sourcemap }, - // context needed as a workaround https://github.com/jtangelder/sass-loader/issues/285 - context: projectRoot, - }, - }) - ].concat(extraPlugins), - node: { - fs: 'empty', - global: true, - crypto: 'empty', - tls: 'empty', - net: 'empty', - process: true, - module: false, - clearImmediate: false, - setImmediate: false - } - }; -} diff --git a/packages/angular-cli/models/webpack-build-development.ts b/packages/angular-cli/models/webpack-build-development.ts deleted file mode 100644 index 21ed45f0e9ca..000000000000 --- a/packages/angular-cli/models/webpack-build-development.ts +++ /dev/null @@ -1,14 +0,0 @@ -const ExtractTextPlugin = require('extract-text-webpack-plugin'); - -export const getWebpackDevConfigPartial = function(projectRoot: string, appConfig: any) { - return { - output: { - filename: '[name].bundle.js', - sourceMapFilename: '[name].bundle.map', - chunkFilename: '[id].chunk.js' - }, - plugins: [ - new ExtractTextPlugin({filename: '[name].bundle.css'}) - ] - }; -}; diff --git a/packages/angular-cli/models/webpack-build-mobile.ts b/packages/angular-cli/models/webpack-build-mobile.ts deleted file mode 100644 index 27f7d3658827..000000000000 --- a/packages/angular-cli/models/webpack-build-mobile.ts +++ /dev/null @@ -1,33 +0,0 @@ -import * as path from 'path'; -const OfflinePlugin = require('offline-plugin'); -import { GlobCopyWebpackPlugin } from '../plugins/glob-copy-webpack-plugin'; -import { PrerenderWebpackPlugin } from '../utilities/prerender-webpack-plugin'; - -export const getWebpackMobileConfigPartial = function (projectRoot: string, appConfig: any) { - // Hardcoded files and paths here should be part of appConfig when - // reworking the mobile app functionality - return { - plugins: [ - new GlobCopyWebpackPlugin({ - patterns: [ 'icons', 'manifest.webapp'], - globOptions: {cwd: appConfig.root, dot: true, ignore: '**/.gitkeep'} - }), - new PrerenderWebpackPlugin({ - templatePath: 'index.html', - configPath: path.resolve(projectRoot, appConfig.root, 'main-app-shell.ts'), - appPath: path.resolve(projectRoot, appConfig.root) - }) - ] - }; -}; - -export const getWebpackMobileProdConfigPartial = function (projectRoot: string, appConfig: any) { - return { - entry: { - 'sw-install': path.resolve(__dirname, '../utilities/sw-install.js') - }, - plugins: [ - new OfflinePlugin() - ] - }; -}; diff --git a/packages/angular-cli/models/webpack-build-production.ts b/packages/angular-cli/models/webpack-build-production.ts deleted file mode 100644 index 1ec187c372b8..000000000000 --- a/packages/angular-cli/models/webpack-build-production.ts +++ /dev/null @@ -1,65 +0,0 @@ -import * as path from 'path'; -import * as webpack from 'webpack'; -const ExtractTextPlugin = require('extract-text-webpack-plugin'); -import {CompressionPlugin} from '../lib/webpack/compression-plugin'; -const autoprefixer = require('autoprefixer'); -const postcssDiscardComments = require('postcss-discard-comments'); - -declare module 'webpack' { - export interface LoaderOptionsPlugin {} - export interface LoaderOptionsPluginStatic { - new (optionsObject: any): LoaderOptionsPlugin; - } - interface Webpack { - LoaderOptionsPlugin: LoaderOptionsPluginStatic; - } -} - -export const getWebpackProdConfigPartial = function(projectRoot: string, - appConfig: any, - sourcemap: boolean, - verbose: any) { - const appRoot = path.resolve(projectRoot, appConfig.root); - - return { - output: { - filename: '[name].[chunkhash].bundle.js', - sourceMapFilename: '[name].[chunkhash].bundle.map', - chunkFilename: '[id].[chunkhash].chunk.js' - }, - plugins: [ - new ExtractTextPlugin('[name].[chunkhash].bundle.css'), - new webpack.DefinePlugin({ - 'process.env.NODE_ENV': JSON.stringify('production') - }), - new webpack.LoaderOptionsPlugin({ minimize: true }), - new webpack.optimize.UglifyJsPlugin({ - mangle: { screw_ie8 : true }, - compress: { screw_ie8: true, warnings: verbose }, - sourceMap: sourcemap - }), - new CompressionPlugin({ - asset: '[path].gz[query]', - algorithm: 'gzip', - test: /\.js$|\.html$|\.css$/, - threshold: 10240 - }), - // LoaderOptionsPlugin needs to be fully duplicated because webpackMerge will replace it. - new webpack.LoaderOptionsPlugin({ - test: /\.(css|scss|sass|less|styl)$/, - options: { - postcss: [ - autoprefixer(), - postcssDiscardComments - ], - cssLoader: { sourceMap: sourcemap }, - sassLoader: { sourceMap: sourcemap }, - lessLoader: { sourceMap: sourcemap }, - stylusLoader: { sourceMap: sourcemap }, - // context needed as a workaround https://github.com/jtangelder/sass-loader/issues/285 - context: projectRoot, - } - }) - ] - }; -}; diff --git a/packages/angular-cli/models/webpack-build-test.js b/packages/angular-cli/models/webpack-build-test.js deleted file mode 100644 index edc93a0105e7..000000000000 --- a/packages/angular-cli/models/webpack-build-test.js +++ /dev/null @@ -1,153 +0,0 @@ -// this config must be JS so that the karma plugin can load it - -const path = require('path'); -const webpack = require('webpack'); -const ngtools = require('@ngtools/webpack'); - - -const g = global; -const webpackLoader = g['angularCliIsLocal'] - ? g.angularCliPackages['@ngtools/webpack'].main - : '@ngtools/webpack'; - -const ProgressPlugin = require('webpack/lib/ProgressPlugin'); - - -/** - * Enumerate loaders and their dependencies from this file to let the dependency validator - * know they are used. - * - * require('tslint-loader') - * require('source-map-loader') - * require('sourcemap-istanbul-instrumenter-loader') - * - * require('remap-istanbul') - * require('tslint') - */ - - -const getWebpackTestConfig = function (projectRoot, environment, appConfig, testConfig) { - - const appRoot = path.resolve(projectRoot, appConfig.root); - const extraRules = []; - const extraPlugins = []; - - if (testConfig.codeCoverage) { - extraRules.push({ - test: /\.(js|ts)$/, loader: 'sourcemap-istanbul-instrumenter-loader', - enforce: 'post', - exclude: [ - /\.(e2e|spec)\.ts$/, - /node_modules/ - ], - query: { 'force-sourcemap': true } - }); - } - - if (testConfig.lint) { - extraRules.push({ - test: /\.ts$/, - enforce: 'pre', - loader: 'tslint-loader', - exclude: [ - path.resolve(projectRoot, 'node_modules') - ] - }); - extraPlugins.push(new webpack.LoaderOptionsPlugin({ - options: { - tslint: { - emitErrors: false, - failOnHint: false, - resourcePath: `./${appConfig.root}` - } - } - })) - } - - if (testConfig.progress) { - extraPlugins.push(new ProgressPlugin({ colors: true })); - } - - return { - devtool: testConfig.sourcemap ? 'inline-source-map' : 'eval', - context: projectRoot, - resolve: { - extensions: ['.ts', '.js'], - plugins: [ - new ngtools.PathsPlugin({ - tsConfigPath: path.resolve(appRoot, appConfig.tsconfig) - }) - ] - }, - entry: { - test: path.resolve(appRoot, appConfig.test) - }, - output: { - path: './dist.test', - filename: '[name].bundle.js' - }, - module: { - rules: [ - { - test: /\.js$/, - enforce: 'pre', - loader: 'source-map-loader', - exclude: [ - /node_modules/ - ] - }, - { - test: /\.ts$/, - loaders: [ - { - loader: webpackLoader, - query: { - tsConfigPath: path.resolve(appRoot, appConfig.tsconfig), - module: 'commonjs' - } - } - ], - exclude: [/\.e2e\.ts$/] - }, - { test: /\.json$/, loader: 'json-loader' }, - { test: /\.css$/, loaders: ['raw-loader', 'postcss-loader'] }, - { test: /\.styl$/, loaders: ['raw-loader', 'postcss-loader', 'stylus-loader'] }, - { test: /\.less$/, loaders: ['raw-loader', 'postcss-loader', 'less-loader'] }, - { test: /\.scss$|\.sass$/, loaders: ['raw-loader', 'postcss-loader', 'sass-loader'] }, - { test: /\.(jpg|png)$/, loader: 'url-loader?limit=128000' }, - { test: /\.html$/, loader: 'raw-loader', exclude: [path.resolve(appRoot, appConfig.index)] } - ].concat(extraRules) - }, - plugins: [ - new webpack.SourceMapDevToolPlugin({ - filename: null, // if no value is provided the sourcemap is inlined - test: /\.(ts|js)($|\?)/i // process .js and .ts files only - }), - new webpack.NormalModuleReplacementPlugin( - // This plugin is responsible for swapping the environment files. - // Since it takes a RegExp as first parameter, we need to escape the path. - // See https://webpack.github.io/docs/list-of-plugins.html#normalmodulereplacementplugin - new RegExp(path.resolve(appRoot, appConfig.environments['source']) - .replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&')), - path.resolve(appRoot, appConfig.environments[environment]) - ), - new webpack.ContextReplacementPlugin( - /angular(\\|\/)core(\\|\/)(esm(\\|\/)src|src)(\\|\/)linker/, - appRoot - ) - ].concat(extraPlugins), - node: { - fs: 'empty', - global: true, - crypto: 'empty', - tls: 'empty', - net: 'empty', - process: true, - module: false, - clearImmediate: false, - setImmediate: false - } - }; -} - -module.exports.getWebpackTestConfig = getWebpackTestConfig; diff --git a/packages/angular-cli/models/webpack-build-typescript.ts b/packages/angular-cli/models/webpack-build-typescript.ts deleted file mode 100644 index 67940af47ab8..000000000000 --- a/packages/angular-cli/models/webpack-build-typescript.ts +++ /dev/null @@ -1,62 +0,0 @@ -import * as path from 'path'; -import {AotPlugin} from '@ngtools/webpack'; - - -const g: any = global; -const webpackLoader: string = g['angularCliIsLocal'] - ? g.angularCliPackages['@ngtools/webpack'].main - : '@ngtools/webpack'; - - -export const getWebpackNonAotConfigPartial = function(projectRoot: string, appConfig: any) { - return { - module: { - rules: [ - { - test: /\.ts$/, - loader: webpackLoader, - exclude: [/\.(spec|e2e)\.ts$/] - } - ] - }, - plugins: [ - new AotPlugin({ - tsConfigPath: path.resolve(projectRoot, appConfig.root, appConfig.tsconfig), - mainPath: path.join(projectRoot, appConfig.root, appConfig.main), - exclude: [ - path.join(projectRoot, appConfig.root, appConfig.test), - '**/*.spec.ts' - ], - skipCodeGeneration: true - }), - ] - }; -}; - -export const getWebpackAotConfigPartial = function(projectRoot: string, appConfig: any, - i18nFile: string, i18nFormat: string, locale: string) { - return { - module: { - rules: [ - { - test: /\.ts$/, - loader: webpackLoader, - exclude: [/\.(spec|e2e)\.ts$/] - } - ] - }, - plugins: [ - new AotPlugin({ - tsConfigPath: path.resolve(projectRoot, appConfig.root, appConfig.tsconfig), - mainPath: path.join(projectRoot, appConfig.root, appConfig.main), - i18nFile: i18nFile, - i18nFormat: i18nFormat, - locale: locale, - exclude: [ - path.join(projectRoot, appConfig.root, appConfig.test), - '**/*.spec.ts' - ] - }) - ] - }; -}; diff --git a/packages/angular-cli/models/webpack-build-utils.ts b/packages/angular-cli/models/webpack-build-utils.ts deleted file mode 100644 index ca12eacbdba3..000000000000 --- a/packages/angular-cli/models/webpack-build-utils.ts +++ /dev/null @@ -1,111 +0,0 @@ -import * as path from 'path'; -const ExtractTextPlugin = require('extract-text-webpack-plugin'); - -/** - * Enumerate loaders and their dependencies from this file to let the dependency validator - * know they are used. - * - * require('raw-loader') - * require('style-loader') - * require('postcss-loader') - * require('css-loader') - * require('stylus-loader') - * require('less-loader') - * require('sass-loader') - * - * require('node-sass') - * require('less') - * require('stylus') - */ - -export const ngAppResolve = (resolvePath: string): string => { - return path.resolve(process.cwd(), resolvePath); -}; - -const webpackOutputOptions = { - colors: true, - hash: true, - timings: true, - chunks: true, - chunkModules: false, - children: false, // listing all children is very noisy in AOT and hides warnings/errors - modules: false, - reasons: false, - warnings: true, - assets: false, // listing all assets is very noisy when using assets directories - version: false -}; - -const verboseWebpackOutputOptions = { - children: true, - assets: true, - version: true, - reasons: true, - chunkModules: false // TODO: set to true when console to file output is fixed -}; - -export function getWebpackStatsConfig(verbose = false) { - return verbose - ? Object.assign(webpackOutputOptions, verboseWebpackOutputOptions) - : webpackOutputOptions; -} - -export interface ExtraEntry { - input: string; - output?: string; - lazy?: boolean; - path?: string; - entry?: string; -} - -// create array of css loaders -export function makeCssLoaders(stylePaths: string[] = []) { - const baseRules = [ - { test: /\.css$/, loaders: [] }, - { test: /\.scss$|\.sass$/, loaders: ['sass-loader'] }, - { test: /\.less$/, loaders: ['less-loader'] }, - { test: /\.styl$/, loaders: ['stylus-loader'] } - ]; - - const commonLoaders = ['postcss-loader']; - - // load component css as raw strings - let cssLoaders: any = baseRules.map(({test, loaders}) => ({ - exclude: stylePaths, test, loaders: ['raw-loader', ...commonLoaders, ...loaders] - })); - - if (stylePaths.length > 0) { - // load global css as css files - cssLoaders.push(...baseRules.map(({test, loaders}) => ({ - include: stylePaths, test, loaders: ExtractTextPlugin.extract({ - remove: false, - loader: ['css-loader', ...commonLoaders, ...loaders], - fallbackLoader: 'style-loader' - }) - }))); - } - - return cssLoaders; -} - -// convert all extra entries into the object representation, fill in defaults -export function extraEntryParser( - extraEntries: (string | ExtraEntry)[], - appRoot: string, - defaultEntry: string -): ExtraEntry[] { - return extraEntries - .map((extraEntry: string | ExtraEntry) => - typeof extraEntry === 'string' ? { input: extraEntry } : extraEntry) - .map((extraEntry: ExtraEntry) => { - extraEntry.path = path.resolve(appRoot, extraEntry.input); - if (extraEntry.output) { - extraEntry.entry = extraEntry.output.replace(/\.(js|css)$/i, ''); - } else if (extraEntry.lazy) { - extraEntry.entry = extraEntry.input.replace(/\.(js|css|scss|sass|less|styl)$/i, ''); - } else { - extraEntry.entry = defaultEntry; - } - return extraEntry; - }); -} diff --git a/packages/angular-cli/models/webpack-config.ts b/packages/angular-cli/models/webpack-config.ts deleted file mode 100644 index b93a3bc24a37..000000000000 --- a/packages/angular-cli/models/webpack-config.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { - getWebpackAotConfigPartial, - getWebpackNonAotConfigPartial -} from './webpack-build-typescript'; -const webpackMerge = require('webpack-merge'); -import { CliConfig } from './config'; -import { getWebpackCommonConfig } from './webpack-build-common'; -import { getWebpackDevConfigPartial } from './webpack-build-development'; -import { getWebpackProdConfigPartial } from './webpack-build-production'; -import { - getWebpackMobileConfigPartial, - getWebpackMobileProdConfigPartial -} from './webpack-build-mobile'; - - -export class NgCliWebpackConfig { - // TODO: When webpack2 types are finished lets replace all these any types - // so this is more maintainable in the future for devs - public config: any; - - constructor( - public ngCliProject: any, - public target: string, - public environment: string, - outputDir?: string, - baseHref?: string, - i18nFile?: string, - i18nFormat?: string, - locale?: string, - isAoT = false, - sourcemap = true, - vendorChunk = false, - verbose = false, - progress = true - ) { - const config: CliConfig = CliConfig.fromProject(); - const appConfig = config.config.apps[0]; - - appConfig.outDir = outputDir || appConfig.outDir; - - let baseConfig = getWebpackCommonConfig( - this.ngCliProject.root, - environment, - appConfig, - baseHref, - sourcemap, - vendorChunk, - verbose, - progress - ); - let targetConfigPartial = this.getTargetConfig( - this.ngCliProject.root, appConfig, sourcemap, verbose - ); - const typescriptConfigPartial = isAoT - ? getWebpackAotConfigPartial(this.ngCliProject.root, appConfig, i18nFile, i18nFormat, locale) - : getWebpackNonAotConfigPartial(this.ngCliProject.root, appConfig); - - if (appConfig.mobile) { - let mobileConfigPartial = getWebpackMobileConfigPartial(this.ngCliProject.root, appConfig); - let mobileProdConfigPartial = getWebpackMobileProdConfigPartial(this.ngCliProject.root, - appConfig); - baseConfig = webpackMerge(baseConfig, mobileConfigPartial); - if (this.target == 'production') { - targetConfigPartial = webpackMerge(targetConfigPartial, mobileProdConfigPartial); - } - } - - this.config = webpackMerge( - baseConfig, - targetConfigPartial, - typescriptConfigPartial - ); - } - - getTargetConfig(projectRoot: string, appConfig: any, sourcemap: boolean, verbose: boolean): any { - switch (this.target) { - case 'development': - return getWebpackDevConfigPartial(projectRoot, appConfig); - case 'production': - return getWebpackProdConfigPartial(projectRoot, appConfig, sourcemap, verbose); - default: - throw new Error("Invalid build target. Only 'development' and 'production' are available."); - } - } -} diff --git a/packages/angular-cli/package.json b/packages/angular-cli/package.json deleted file mode 100644 index 24fed046dc80..000000000000 --- a/packages/angular-cli/package.json +++ /dev/null @@ -1,114 +0,0 @@ -{ - "name": "angular-cli", - "version": "1.0.0-beta.24", - "description": "CLI tool for Angular", - "main": "lib/cli/index.js", - "trackingCode": "UA-8594346-19", - "bin": { - "ng": "./bin/ng" - }, - "keywords": [ - "ember-addon" - ], - "repository": { - "type": "git", - "url": "https://github.com/angular/angular-cli.git" - }, - "engines": { - "node": ">= 4.1.0", - "npm": ">= 3.0.0" - }, - "author": "Angular Authors", - "license": "MIT", - "bugs": { - "url": "https://github.com/angular/angular-cli/issues" - }, - "homepage": "https://github.com/angular/angular-cli", - "dependencies": { - "@angular-cli/ast-tools": "^1.0.1", - "@angular-cli/base-href-webpack": "^1.0.0", - "@angular/compiler": "^2.3.1", - "@angular/compiler-cli": "^2.3.1", - "@angular/core": "^2.3.1", - "@ngtools/webpack": "^1.0.0", - "async": "^2.1.4", - "autoprefixer": "^6.5.3", - "chalk": "^1.1.3", - "common-tags": "^1.3.1", - "configstore": "^2.0.0", - "css-loader": "^0.23.1", - "debug": "^2.1.3", - "denodeify": "^1.2.1", - "diff": "^2.2.2", - "ember-cli-normalize-entity-name": "^1.0.0", - "ember-cli-preprocess-registry": "^2.0.0", - "ember-cli-string-utils": "^1.0.0", - "exists-sync": "0.0.3", - "extract-text-webpack-plugin": "^2.0.0-beta.4", - "file-loader": "^0.8.5", - "findup": "0.1.5", - "fs-extra": "^0.30.0", - "get-caller-file": "^1.0.0", - "git-repo-info": "^1.0.4", - "glob": "^7.0.3", - "html-webpack-plugin": "^2.19.0", - "inflection": "^1.7.0", - "inquirer": "^0.12.0", - "is-git-url": "^0.2.0", - "isbinaryfile": "^2.0.3", - "json-loader": "^0.5.4", - "karma-sourcemap-loader": "^0.3.7", - "karma-webpack": "^1.8.0", - "less": "^2.7.1", - "less-loader": "^2.2.3", - "lodash": "^4.11.1", - "markdown-it": "4.3.0", - "markdown-it-terminal": "0.0.3", - "minimatch": "^3.0.3", - "node-modules-path": "^1.0.0", - "node-sass": "^3.10.1", - "nopt": "^3.0.1", - "offline-plugin": "^3.4.1", - "opn": "4.0.1", - "ora": "^0.2.0", - "portfinder": "1.0.9", - "postcss-discard-comments": "^2.0.4", - "postcss-loader": "^0.9.1", - "quick-temp": "0.1.5", - "raw-loader": "^0.5.1", - "readline2": "0.1.1", - "remap-istanbul": "^0.6.4", - "resolve": "^1.1.7", - "rimraf": "^2.5.3", - "rsvp": "^3.0.17", - "sass-loader": "^4.0.1", - "script-loader": "^0.7.0", - "semver": "^5.1.0", - "silent-error": "^1.0.0", - "source-map-loader": "^0.1.5", - "sourcemap-istanbul-instrumenter-loader": "^0.2.0", - "style-loader": "^0.13.1", - "stylus": "^0.54.5", - "stylus-loader": "^2.1.0", - "temp": "0.8.3", - "through": "^2.3.6", - "tslint": "^4.0.2", - "tslint-loader": "^3.3.0", - "typescript": "~2.0.3", - "url-loader": "^0.5.7", - "uuid": "^3.0.0", - "walk-sync": "^0.2.6", - "webpack": "2.1.0-beta.25", - "webpack-dev-server": "2.1.0-beta.9", - "webpack-merge": "^0.14.0", - "webpack-sources": "^0.1.3", - "yam": "0.0.18", - "zone.js": "^0.7.2" - }, - "ember-addon": { - "paths": [ - "./" - ], - "main": "./addon/index.js" - } -} diff --git a/packages/angular-cli/plugins/glob-copy-webpack-plugin.ts b/packages/angular-cli/plugins/glob-copy-webpack-plugin.ts deleted file mode 100644 index c5deea9c658e..000000000000 --- a/packages/angular-cli/plugins/glob-copy-webpack-plugin.ts +++ /dev/null @@ -1,51 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; -import * as glob from 'glob'; -import * as denodeify from 'denodeify'; - -const globPromise = denodeify(glob); -const statPromise = denodeify(fs.stat); - -export interface GlobCopyWebpackPluginOptions { - patterns: string[]; - globOptions: any; -} - -export class GlobCopyWebpackPlugin { - constructor(private options: GlobCopyWebpackPluginOptions) { } - - apply(compiler: any): void { - let { patterns, globOptions } = this.options; - let context = globOptions.cwd || compiler.options.context; - - // convert dir patterns to globs - patterns = patterns.map(pattern => fs.statSync(path.resolve(context, pattern)).isDirectory() - ? pattern += '/**/*' - : pattern - ); - - // force nodir option, since we can't add dirs to assets - globOptions.nodir = true; - - compiler.plugin('emit', (compilation: any, cb: any) => { - let globs = patterns.map(pattern => globPromise(pattern, globOptions)); - - let addAsset = (relPath: string) => compilation.assets[relPath] - // don't re-add to assets - ? Promise.resolve() - : statPromise(path.resolve(context, relPath)) - .then((stat: any) => compilation.assets[relPath] = { - size: () => stat.size, - source: () => fs.readFileSync(path.resolve(context, relPath)) - }); - - Promise.all(globs) - // flatten results - .then(globResults => [].concat.apply([], globResults)) - // add each file to compilation assets - .then(relPaths => relPaths.forEach((relPath: string) => addAsset(relPath))) - .catch((err) => compilation.errors.push(err)) - .then(cb); - }); - } -} diff --git a/packages/angular-cli/plugins/karma.js b/packages/angular-cli/plugins/karma.js deleted file mode 100644 index 16c4fc7cafea..000000000000 --- a/packages/angular-cli/plugins/karma.js +++ /dev/null @@ -1,101 +0,0 @@ -const path = require('path'); -const fs = require('fs'); - -const getWebpackTestConfig = require('../models/webpack-build-test').getWebpackTestConfig; -const CliConfig = require('../models/config').CliConfig; - -const init = (config) => { - // load Angular CLI config - if (!config.angularCli || !config.angularCli.config) { - throw new Error('Missing \'angularCli.config\' entry in Karma config'); - } - const angularCliConfig = require(path.join(config.basePath, config.angularCli.config)); - const appConfig = angularCliConfig.apps[0]; - const appRoot = path.join(config.basePath, appConfig.root); - const environment = config.angularCli.environment || 'dev'; - const testConfig = { - codeCoverage: config.angularCli.codeCoverage || false, - lint: config.angularCli.lint || false, - sourcemap: config.angularCli.sourcemap, - progress: config.angularCli.progress - } - - // add assets - if (appConfig.assets) { - const assets = typeof appConfig.assets === 'string' ? [appConfig.assets] : appConfig.assets; - config.proxies = config.proxies || {}; - assets.forEach(asset => { - const fullAssetPath = path.join(config.basePath, appConfig.root, asset); - const isDirectory = fs.lstatSync(fullAssetPath).isDirectory(); - const filePattern = isDirectory ? fullAssetPath + '/**' : fullAssetPath; - const proxyPath = isDirectory ? asset + '/' : asset; - config.files.push({ - pattern: filePattern, - included: false, - served: true, - watched: true - }); - // The `files` entry serves the file from `/base/{appConfig.root}/{asset}` - // so, we need to add a URL rewrite that exposes the asset as `/{asset}` only - config.proxies['/' + proxyPath] = '/base/' + appConfig.root + '/' + proxyPath; - }); - } - - // add webpack config - const webpackConfig = getWebpackTestConfig(config.basePath, environment, appConfig, testConfig); - const webpackMiddlewareConfig = { - noInfo: true, // Hide webpack output because its noisy. - stats: { // Also prevent chunk and module display output, cleaner look. Only emit errors. - assets: false, - colors: true, - version: false, - hash: false, - timings: false, - chunks: false, - chunkModules: false - }, - watchOptions: { - poll: CliConfig.fromProject().config.defaults.poll - } - }; - - config.webpack = Object.assign(webpackConfig, config.webpack); - config.webpackMiddleware = Object.assign(webpackMiddlewareConfig, config.webpackMiddleware); - - // replace the angular-cli preprocessor with webpack+sourcemap - Object.keys(config.preprocessors) - .filter((file) => config.preprocessors[file].indexOf('angular-cli') !== -1) - .map((file) => config.preprocessors[file]) - .map((arr) => arr.splice(arr.indexOf('angular-cli'), 1, 'webpack', 'sourcemap')); - - // Add global scripts - if (appConfig.scripts && appConfig.scripts.length > 0) { - const globalScriptPatterns = appConfig.scripts - .map(script => typeof script === 'string' ? { input: script } : script) - // Neither renamed nor lazy scripts are currently supported - .filter(script => !(script.output || script.lazy)) - .map(script => ({ - pattern: path.resolve(appRoot, script.input), - included: true, - served: true, - watched: true - })); - - // Unshift elements onto the beginning of the files array. - // It's important to not replace the array, because - // karma already has a reference to the existing array. - Array.prototype.unshift.apply(config.files, globalScriptPatterns); - } -} - -init.$inject = ['config']; - -// dummy preprocessor, just to keep karma from showing a warning -const preprocessor = () => (content, file, done) => done(null, content); -preprocessor.$inject = []; - -// also export karma-webpack and karma-sourcemap-loader -module.exports = Object.assign({ - 'framework:angular-cli': ['factory', init], - 'preprocessor:angular-cli': ['factory', preprocessor] -}, require('karma-webpack'), require('karma-sourcemap-loader')); diff --git a/packages/angular-cli/plugins/suppress-entry-chunks-webpack-plugin.ts b/packages/angular-cli/plugins/suppress-entry-chunks-webpack-plugin.ts deleted file mode 100644 index 0d62fc4f3ee3..000000000000 --- a/packages/angular-cli/plugins/suppress-entry-chunks-webpack-plugin.ts +++ /dev/null @@ -1,44 +0,0 @@ -// ExtractTextPlugin leaves behind the entry points, which we might not need anymore -// if they were entirely css. This plugin removes those entry points. - -export interface SuppressEntryChunksWebpackPluginOptions { - chunks: string[]; -} - -export class SuppressEntryChunksWebpackPlugin { - constructor(private options: SuppressEntryChunksWebpackPluginOptions) { } - - apply(compiler: any): void { - let { chunks } = this.options; - compiler.plugin('compilation', function (compilation: any) { - // Remove the js file for supressed chunks - compilation.plugin('after-seal', (callback: any) => { - compilation.chunks - .filter((chunk: any) => chunks.indexOf(chunk.name) !== -1) - .forEach((chunk: any) => { - let newFiles: string[] = []; - chunk.files.forEach((file: string) => { - if (file.match(/\.js$/)) { - // remove js files - delete compilation.assets[file]; - } else { - newFiles.push(file); - } - }); - chunk.files = newFiles; - }); - callback(); - }); - // Remove scripts tags with a css file as source, because HtmlWebpackPlugin will use - // a css file as a script for chunks without js files. - compilation.plugin('html-webpack-plugin-alter-asset-tags', - (htmlPluginData: any, callback: any) => { - const filterFn = (tag: any) => - !(tag.tagName === 'script' && tag.attributes.src.match(/\.css$/)); - htmlPluginData.head = htmlPluginData.head.filter(filterFn); - htmlPluginData.body = htmlPluginData.body.filter(filterFn); - callback(null, htmlPluginData); - }); - }); - } -} diff --git a/packages/angular-cli/tasks/build-webpack-watch.ts b/packages/angular-cli/tasks/build-webpack-watch.ts deleted file mode 100644 index 4a793dc5d9bd..000000000000 --- a/packages/angular-cli/tasks/build-webpack-watch.ts +++ /dev/null @@ -1,55 +0,0 @@ -import * as rimraf from 'rimraf'; -import * as path from 'path'; -const Task = require('../ember-cli/lib/models/task'); -import * as webpack from 'webpack'; -import { NgCliWebpackConfig } from '../models/webpack-config'; -import { getWebpackStatsConfig } from '../models/'; -import { BuildOptions } from '../commands/build'; -import { CliConfig } from '../models/config'; - -let lastHash: any = null; - -export default Task.extend({ - run: function(runTaskOptions: BuildOptions) { - - const project = this.cliProject; - - const outputDir = runTaskOptions.outputPath || CliConfig.fromProject().config.apps[0].outDir; - rimraf.sync(path.resolve(project.root, outputDir)); - - const config = new NgCliWebpackConfig( - project, - runTaskOptions.target, - runTaskOptions.environment, - outputDir, - runTaskOptions.baseHref, - runTaskOptions.i18nFile, - runTaskOptions.i18nFormat, - runTaskOptions.locale, - runTaskOptions.aot, - runTaskOptions.sourcemap, - runTaskOptions.vendorChunk, - runTaskOptions.verbose, - runTaskOptions.progress - ).config; - const webpackCompiler: any = webpack(config); - - const statsConfig = getWebpackStatsConfig(runTaskOptions.verbose); - - return new Promise((resolve, reject) => { - webpackCompiler.watch({}, (err: any, stats: any) => { - if (err) { - lastHash = null; - console.error(err.stack || err); - if (err.details) { console.error(err.details); } - reject(err.details); - } - - if (stats.hash !== lastHash) { - lastHash = stats.hash; - process.stdout.write(stats.toString(statsConfig) + '\n'); - } - }); - }); - } -}); diff --git a/packages/angular-cli/tasks/build-webpack.ts b/packages/angular-cli/tasks/build-webpack.ts deleted file mode 100644 index 5e76299c55dc..000000000000 --- a/packages/angular-cli/tasks/build-webpack.ts +++ /dev/null @@ -1,70 +0,0 @@ -import * as rimraf from 'rimraf'; -import * as path from 'path'; -const Task = require('../ember-cli/lib/models/task'); -import * as webpack from 'webpack'; -import { BuildOptions } from '../commands/build'; -import { NgCliWebpackConfig } from '../models/webpack-config'; -import { getWebpackStatsConfig } from '../models/'; -import { CliConfig } from '../models/config'; - - -// Configure build and output; -let lastHash: any = null; - -export default Task.extend({ - run: function (runTaskOptions: BuildOptions) { - - const project = this.cliProject; - - const outputDir = runTaskOptions.outputPath || CliConfig.fromProject().config.apps[0].outDir; - rimraf.sync(path.resolve(project.root, outputDir)); - const config = new NgCliWebpackConfig( - project, - runTaskOptions.target, - runTaskOptions.environment, - outputDir, - runTaskOptions.baseHref, - runTaskOptions.i18nFile, - runTaskOptions.i18nFormat, - runTaskOptions.locale, - runTaskOptions.aot, - runTaskOptions.sourcemap, - runTaskOptions.vendorChunk, - runTaskOptions.verbose, - runTaskOptions.progress - ).config; - - const webpackCompiler: any = webpack(config); - - const statsConfig = getWebpackStatsConfig(runTaskOptions.verbose); - - return new Promise((resolve, reject) => { - webpackCompiler.run((err: any, stats: any) => { - if (err) { - return reject(err); - } - - // Don't keep cache - // TODO: Make conditional if using --watch - webpackCompiler.purgeInputFileSystem(); - - if (stats.hash !== lastHash) { - lastHash = stats.hash; - process.stdout.write(stats.toString(statsConfig) + '\n'); - } - - if (stats.hasErrors()) { - reject(); - } else { - resolve(); - } - }); - }) - .catch((err: Error) => { - if (err) { - this.ui.writeError('\nAn error occured during the build:\n' + ((err && err.stack) || err)); - } - throw err; - }); - } -}); diff --git a/packages/angular-cli/tasks/create-github-repo.ts b/packages/angular-cli/tasks/create-github-repo.ts deleted file mode 100644 index 554d38396671..000000000000 --- a/packages/angular-cli/tasks/create-github-repo.ts +++ /dev/null @@ -1,92 +0,0 @@ -import * as denodeify from 'denodeify'; -const Task = require('../ember-cli/lib/models/task'); -const SilentError = require('silent-error'); -import { exec } from 'child_process'; -import * as https from 'https'; -import { oneLine } from 'common-tags'; - - -export default Task.extend({ - run: function(commandOptions: any) { - const ui = this.ui; - let promise: Promise; - - // declared here so that tests can stub exec - const execPromise = denodeify(exec); - - if (/.+/.test(commandOptions.ghToken) && /\w+/.test(commandOptions.ghUsername)) { - promise = Promise.resolve({ - ghToken: commandOptions.ghToken, - ghUsername: commandOptions.ghUsername - }); - } else { - ui.writeLine(); - ui.writeLine(oneLine` - In order to deploy this project via GitHub Pages, we must first create a repository for it. - `); - ui.writeLine(oneLine` - It\'s safer to use a token than to use a password so you will need to create one - `); - ui.writeLine('Go to the following page and click "Generate new token".'); - ui.writeLine('https://github.com/settings/tokens\n'); - ui.writeLine('Choose "public_repo" as scope and then click "Generate token".\n'); - promise = ui.prompt([ - { - name: 'ghToken', - type: 'input', - message: oneLine` - Please enter GitHub token you just created - (used only once to create the repo): - `, - validate: function(token: string) { - return /.+/.test(token); - } - }, { - name: 'ghUsername', - type: 'input', - message: 'and your GitHub user name:', - validate: function(userName: string) { - return /\w+/.test(userName); - } - }]); - } - - return promise - .then((answers) => { - return new Promise(function(resolve, reject) { - const postData = JSON.stringify({ - 'name': commandOptions.projectName - }); - - const req = https.request({ - hostname: 'api.github.com', - port: 443, - path: '/user/repos', - method: 'POST', - headers: { - 'Authorization': `token ${answers.ghToken}`, - 'Content-Type': 'application/json', - 'Content-Length': postData.length, - 'User-Agent': 'angular-cli-github-pages' - } - }); - - req.on('response', function(response: any) { - if (response.statusCode === 201) { - resolve(execPromise(oneLine` - git remote add origin - git@github.com:${answers.ghUsername}/${commandOptions.projectName}.git - `)); - } else { - reject(new SilentError(oneLine` - Failed to create GitHub repo. Error: ${response.statusCode} ${response.statusMessage} - `)); - } - }); - - req.write(postData); - req.end(); - }); - }); - } -}); diff --git a/packages/angular-cli/tasks/doc.ts b/packages/angular-cli/tasks/doc.ts deleted file mode 100644 index c144b71971e7..000000000000 --- a/packages/angular-cli/tasks/doc.ts +++ /dev/null @@ -1,9 +0,0 @@ -const Task = require('../ember-cli/lib/models/task'); -const opn = require('opn'); - -export const DocTask: any = Task.extend({ - run: function(keyword: string) { - const searchUrl = `https://angular.io/docs/ts/latest/api/#!?query=${keyword}`; - return opn(searchUrl, { wait: false }); - } -}); diff --git a/packages/angular-cli/tasks/e2e.ts b/packages/angular-cli/tasks/e2e.ts deleted file mode 100644 index 4de5cbac8fc7..000000000000 --- a/packages/angular-cli/tasks/e2e.ts +++ /dev/null @@ -1,26 +0,0 @@ -const Task = require('../ember-cli/lib/models/task'); -import * as chalk from 'chalk'; -import {exec} from 'child_process'; - - -export const E2eTask = Task.extend({ - run: function () { - const ui = this.ui; - let exitCode = 0; - - return new Promise((resolve) => { - exec(`npm run e2e -- ${this.project.ngConfig.config.e2e.protractor.config}`, - (err: NodeJS.ErrnoException, stdout: string, stderr: string) => { - ui.writeLine(stdout); - if (err) { - ui.writeLine(stderr); - ui.writeLine(chalk.red('Some end-to-end tests failed, see above.')); - exitCode = 1; - } else { - ui.writeLine(chalk.green('All end-to-end tests pass.')); - } - resolve(exitCode); - }); - }); - } -}); diff --git a/packages/angular-cli/tasks/git-init.js b/packages/angular-cli/tasks/git-init.js deleted file mode 100644 index 1ee9655061f0..000000000000 --- a/packages/angular-cli/tasks/git-init.js +++ /dev/null @@ -1,68 +0,0 @@ -'use strict'; - -var Promise = require('../ember-cli/lib/ext/promise'); -var exec = Promise.denodeify(require('child_process').exec); -var path = require('path'); -var pkg = require('../package.json'); -var fs = require('fs'); -var template = require('lodash/template'); -var Task = require('../ember-cli/lib/models/task'); - -var gitEnvironmentVariables = { - GIT_AUTHOR_NAME: 'angular-cli', - GIT_AUTHOR_EMAIL: 'angular-cli@angular.io', - get GIT_COMMITTER_NAME() { - return this.GIT_AUTHOR_NAME; - }, - get GIT_COMMITTER_EMAIL() { - return this.GIT_AUTHOR_EMAIL; - } -}; - -module.exports = Task.extend({ - run: function (commandOptions) { - var chalk = require('chalk'); - var ui = this.ui; - - if (commandOptions.skipGit) { - return Promise.resolve(); - } - - return exec('git --version') - .then(function () { - // check if we're inside a git repo - return exec('git rev-parse --is-inside-work-tree') - .then(function () { - return true; - }) - .catch(function() { - return false; - }) - }) - .then(function (insideGitRepo) { - if (insideGitRepo) { - return ui.writeLine('Directory is already under version control. Skipping initialization of git.'); - } - return exec('git init') - .then(function () { - return exec('git add .'); - }) - .then(function () { - var commitTemplate = fs.readFileSync( - path.join(__dirname, '../utilities/INITIAL_COMMIT_MESSAGE.txt')); - var commitMessage = template(commitTemplate)(pkg); - return exec( - 'git commit -m "' + commitMessage + '"', { env: gitEnvironmentVariables }); - }) - .then(function () { - ui.writeLine(chalk.green('Successfully initialized git.')); - }); - }) - .catch(function (/*error*/) { - // if git is not found or an error was thrown during the `git` - // init process just swallow any errors here - }); - } -}); - -module.exports.overrideCore = true; diff --git a/packages/angular-cli/tasks/link-cli.ts b/packages/angular-cli/tasks/link-cli.ts deleted file mode 100644 index cb2ee1175e68..000000000000 --- a/packages/angular-cli/tasks/link-cli.ts +++ /dev/null @@ -1,21 +0,0 @@ -const Task = require('../ember-cli/lib/models/task'); -import * as chalk from 'chalk'; -import {exec} from 'child_process'; - -export default Task.extend({ - run: function() { - const ui = this.ui; - - return new Promise(function(resolve, reject) { - exec('npm link angular-cli', (err) => { - if (err) { - ui.writeLine(chalk.red('Couldn\'t do \'npm link angular-cli\'.')); - reject(); - } else { - ui.writeLine(chalk.green('Successfully linked to angular-cli.')); - resolve(); - } - }); - }); - } -}); diff --git a/packages/angular-cli/tasks/lint.ts b/packages/angular-cli/tasks/lint.ts deleted file mode 100644 index 08f9d5a653e2..000000000000 --- a/packages/angular-cli/tasks/lint.ts +++ /dev/null @@ -1,22 +0,0 @@ -const Task = require('../ember-cli/lib/models/task'); -import * as chalk from 'chalk'; -import {exec} from 'child_process'; - -export default Task.extend({ - run: function () { - const ui = this.ui; - - return new Promise(function(resolve, reject) { - exec('npm run lint', (err, stdout) => { - ui.writeLine(stdout); - if (err) { - ui.writeLine(chalk.red('Lint errors found in the listed files.')); - reject(); - } else { - ui.writeLine(chalk.green('All files pass linting.')); - resolve(); - } - }); - }); - } -}); diff --git a/packages/angular-cli/tasks/npm-install.ts b/packages/angular-cli/tasks/npm-install.ts deleted file mode 100644 index 02e3f5d1cc27..000000000000 --- a/packages/angular-cli/tasks/npm-install.ts +++ /dev/null @@ -1,25 +0,0 @@ -const Task = require('../ember-cli/lib/models/task'); -import * as chalk from 'chalk'; -import {exec} from 'child_process'; - - -export default Task.extend({ - run: function() { - const ui = this.ui; - - return new Promise(function(resolve, reject) { - ui.writeLine(chalk.green('Installing packages for tooling via npm.')); - exec('npm install', - (err: NodeJS.ErrnoException, stdout: string, stderr: string) => { - if (err) { - ui.writeLine(stderr); - ui.writeLine(chalk.red('Package install failed, see above.')); - reject(); - } else { - ui.writeLine(chalk.green('Installed packages for tooling via npm.')); - resolve(); - } - }); - }); - } -}); diff --git a/packages/angular-cli/tasks/serve-webpack.ts b/packages/angular-cli/tasks/serve-webpack.ts deleted file mode 100644 index 6b491071be97..000000000000 --- a/packages/angular-cli/tasks/serve-webpack.ts +++ /dev/null @@ -1,148 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; -import * as chalk from 'chalk'; -const SilentError = require('silent-error'); -const Task = require('../ember-cli/lib/models/task'); -import * as webpack from 'webpack'; -const WebpackDevServer = require('webpack-dev-server'); -import { getWebpackStatsConfig } from '../models/'; -import { NgCliWebpackConfig } from '../models/webpack-config'; -import { ServeTaskOptions } from '../commands/serve'; -import { CliConfig } from '../models/config'; -import { oneLine } from 'common-tags'; -import * as url from 'url'; -import {stripIndents} from 'common-tags'; -const opn = require('opn'); - -export default Task.extend({ - run: function(serveTaskOptions: ServeTaskOptions) { - const ui = this.ui; - - let webpackCompiler: any; - - let config = new NgCliWebpackConfig( - this.project, - serveTaskOptions.target, - serveTaskOptions.environment, - undefined, - undefined, - serveTaskOptions.i18nFile, - serveTaskOptions.i18nFormat, - serveTaskOptions.locale, - serveTaskOptions.aot, - serveTaskOptions.sourcemap, - serveTaskOptions.vendorChunk, - serveTaskOptions.verbose, - serveTaskOptions.progress - ).config; - - // This allows for live reload of page when changes are made to repo. - // https://webpack.github.io/docs/webpack-dev-server.html#inline-mode - let entryPoints = [ - `webpack-dev-server/client?http://${serveTaskOptions.host}:${serveTaskOptions.port}/` - ]; - if (serveTaskOptions.hmr) { - const webpackHmrLink = 'https://webpack.github.io/docs/hot-module-replacement.html'; - ui.writeLine(oneLine` - ${chalk.yellow('NOTICE')} Hot Module Replacement (HMR) is enabled for the dev server. - `); - ui.writeLine(' The project will still live reload when HMR is enabled,'); - ui.writeLine(' but to take advantage of HMR additional application code is required'); - ui.writeLine(' (not included in an angular-cli project by default).'); - ui.writeLine(` See ${chalk.blue(webpackHmrLink)}`); - ui.writeLine(' for information on working with HMR for Webpack.'); - entryPoints.push('webpack/hot/dev-server'); - config.plugins.push(new webpack.HotModuleReplacementPlugin()); - } - config.entry.main.unshift(...entryPoints); - webpackCompiler = webpack(config); - - const statsConfig = getWebpackStatsConfig(serveTaskOptions.verbose); - - let proxyConfig = {}; - if (serveTaskOptions.proxyConfig) { - const proxyPath = path.resolve(this.project.root, serveTaskOptions.proxyConfig); - if (fs.existsSync(proxyPath)) { - proxyConfig = require(proxyPath); - } else { - const message = 'Proxy config file ' + proxyPath + ' does not exist.'; - return Promise.reject(new SilentError(message)); - } - } - - let sslKey: string = null; - let sslCert: string = null; - if (serveTaskOptions.ssl) { - const keyPath = path.resolve(this.project.root, serveTaskOptions.sslKey); - if (fs.existsSync(keyPath)) { - sslKey = fs.readFileSync(keyPath, 'utf-8'); - } - const certPath = path.resolve(this.project.root, serveTaskOptions.sslCert); - if (fs.existsSync(certPath)) { - sslCert = fs.readFileSync(certPath, 'utf-8'); - } - } - - const webpackDevServerConfiguration: IWebpackDevServerConfigurationOptions = { - contentBase: path.resolve( - this.project.root, - `./${CliConfig.fromProject().config.apps[0].root}` - ), - headers: { 'Access-Control-Allow-Origin': '*' }, - historyApiFallback: { - disableDotRule: true, - htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'] - }, - stats: statsConfig, - inline: true, - proxy: proxyConfig, - compress: serveTaskOptions.target === 'production', - watchOptions: { - poll: CliConfig.fromProject().config.defaults.poll - }, - https: serveTaskOptions.ssl - }; - - if (sslKey != null && sslCert != null) { - webpackDevServerConfiguration.key = sslKey; - webpackDevServerConfiguration.cert = sslCert; - } - - webpackDevServerConfiguration.hot = serveTaskOptions.hmr; - - if (serveTaskOptions.target === 'production') { - ui.writeLine(chalk.red(stripIndents` - **************************************************************************************** - This is a simple server for use in testing or debugging Angular applications locally. - It hasn't been reviewed for security issues. - - DON'T USE IT FOR PRODUCTION USE! - **************************************************************************************** - `)); - } - - ui.writeLine(chalk.green(oneLine` - ** - NG Live Development Server is running on - http${serveTaskOptions.ssl ? 's' : ''}://${serveTaskOptions.host}:${serveTaskOptions.port}. - ** - `)); - - const server = new WebpackDevServer(webpackCompiler, webpackDevServerConfiguration); - return new Promise((resolve, reject) => { - server.listen(serveTaskOptions.port, `${serveTaskOptions.host}`, (err: any, stats: any) => { - if (err) { - console.error(err.stack || err); - if (err.details) { console.error(err.details); } - reject(err.details); - } else { - const { open, ssl, host, port } = serveTaskOptions; - if (open) { - let protocol = ssl ? 'https' : 'http'; - opn(url.format({ protocol: protocol, hostname: host, port: port.toString() })); - } - } - }); - }); - } -}); diff --git a/packages/angular-cli/tasks/test.ts b/packages/angular-cli/tasks/test.ts deleted file mode 100644 index 648314d88764..000000000000 --- a/packages/angular-cli/tasks/test.ts +++ /dev/null @@ -1,41 +0,0 @@ -const Task = require('../ember-cli/lib/models/task'); -import { TestOptions } from '../commands/test'; -import * as path from 'path'; - -// require dependencies within the target project -function requireDependency(root: string, moduleName: string) { - const packageJson = require(path.join(root, 'node_modules', moduleName, 'package.json')); - const main = path.normalize(packageJson.main); - return require(path.join(root, 'node_modules', moduleName, main)); -} - -export default Task.extend({ - run: function (options: TestOptions) { - const projectRoot = this.project.root; - return new Promise((resolve) => { - const karma = requireDependency(projectRoot, 'karma'); - const karmaConfig = path.join(projectRoot, this.project.ngConfig.config.test.karma.config); - - let karmaOptions: any = Object.assign({}, options); - - // Convert browsers from a string to an array - if (options.browsers) { - karmaOptions.browsers = options.browsers.split(','); - } - - karmaOptions.angularCli = { - codeCoverage: options.codeCoverage, - lint: options.lint, - sourcemap: options.sourcemap, - progress: options.progress - }; - - // Assign additional karmaConfig options to the local ngapp config - karmaOptions.configFile = karmaConfig; - - // :shipit: - const karmaServer = new karma.Server(karmaOptions, resolve); - karmaServer.start(); - }); - } -}); diff --git a/packages/angular-cli/tsconfig.json b/packages/angular-cli/tsconfig.json deleted file mode 100644 index f771c2461b8d..000000000000 --- a/packages/angular-cli/tsconfig.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "compilerOptions": { - "declaration": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "mapRoot": "", - "module": "commonjs", - "moduleResolution": "node", - "noEmitOnError": true, - "noImplicitAny": true, - "outDir": "../../dist/angular-cli", - "rootDir": ".", - "sourceMap": true, - "sourceRoot": "/", - "target": "es5", - "lib": ["es6"], - "skipLibCheck": true, - "typeRoots": [ - "../../node_modules/@types" - ], - "baseUrl": "", - "paths": { - "@angular-cli/ast-tools": [ "../../dist/@angular-cli/ast-tools/src" ], - "@angular-cli/base-href-webpack": [ "../../dist/@angular-cli/base-href-webpack/src" ], - "@ngtools/webpack": [ "../../dist/@ngtools/webpack/src" ] - } - }, - "include": [ - "**/*" - ], - "exclude": [ - "blueprints/*/files/**/*" - ] -} diff --git a/packages/angular-cli/upgrade/version.ts b/packages/angular-cli/upgrade/version.ts deleted file mode 100644 index 15decc70923f..000000000000 --- a/packages/angular-cli/upgrade/version.ts +++ /dev/null @@ -1,171 +0,0 @@ -import {CliConfig} from '../models/config'; -import {readFileSync, existsSync} from 'fs'; -import {stripIndents} from 'common-tags'; -import {bold, red, yellow} from 'chalk'; -import * as path from 'path'; -const resolve = require('resolve'); - - -function _findUp(name: string, from: string) { - let currentDir = from; - while (currentDir && currentDir !== path.parse(currentDir).root) { - const p = path.join(currentDir, name); - if (existsSync(p)) { - return p; - } - - currentDir = path.dirname(currentDir); - } - - return null; -} - - -function _hasOldCliBuildFile() { - return existsSync(_findUp('angular-cli-build.js', process.cwd())) - || existsSync(_findUp('angular-cli-build.ts', process.cwd())) - || existsSync(_findUp('ember-cli-build.js', process.cwd())) - || existsSync(_findUp('angular-cli-build.js', __dirname)) - || existsSync(_findUp('angular-cli-build.ts', __dirname)) - || existsSync(_findUp('ember-cli-build.js', __dirname)); -} - - -export class Version { - constructor(private _version: string = null) {} - - private _parse() { - return this.isKnown() - ? this._version.match(/^(\d+)\.(\d+)(?:\.(\d+))?(?:-(alpha|beta|rc)\.(.*))?$/).slice(1) - : []; - } - - isAlpha() { return this.qualifier == 'alpha'; } - isBeta() { return this.qualifier == 'beta'; } - isReleaseCandidate() { return this.qualifier == 'rc'; } - isKnown() { return this._version !== null; } - - isLocal() { return this.isKnown() && path.isAbsolute(this._version); } - - get major() { return this._parse()[0] || 0; } - get minor() { return this._parse()[1] || 0; } - get patch() { return this._parse()[2] || 0; } - get qualifier() { return this._parse()[3] || ''; } - get extra() { return this._parse()[4] || ''; } - - toString() { return this._version; } - - static fromProject(): Version { - let packageJson: any = null; - - try { - const angularCliPath = resolve.sync('angular-cli', { - basedir: process.cwd(), - packageFilter: (pkg: any, pkgFile: string) => { - packageJson = pkg; - } - }); - if (angularCliPath && packageJson) { - try { - return new Version(packageJson.version); - } catch (e) { - return new Version(null); - } - } - } catch (e) { - // Fallback to reading config. - } - - - const configPath = CliConfig.configFilePath(); - - if (configPath === null) { - return new Version(null); - } - - const configJson = readFileSync(configPath, 'utf8'); - - try { - const json = JSON.parse(configJson); - return new Version(json.project && json.project.version); - } catch (e) { - return new Version(null); - } - } - - static assertAngularVersionIs2_3_1OrHigher(projectRoot: string) { - const angularCorePath = path.join(projectRoot, 'node_modules/@angular/core'); - const pkgJson = existsSync(angularCorePath) - ? JSON.parse(readFileSync(path.join(angularCorePath, 'package.json'), 'utf8')) - : null; - - // Just check @angular/core. - if (pkgJson && pkgJson['version']) { - const v = new Version(pkgJson['version']); - if (v.isLocal()) { - console.warn(yellow('Using a local version of angular. Proceeding with care...')); - } else { - if (v.major != 2 || ((v.minor == 3 && v.patch == 0) || v.minor < 3)) { - console.error(bold(red(stripIndents` - This version of CLI is only compatible with angular version 2.3.1 or better. Please - upgrade your angular version, e.g. by running: - - npm install @angular/core@latest - ` + '\n'))); - process.exit(3); - } - } - } else { - console.error(bold(red(stripIndents` - You seem to not be dependending on "@angular/core". This is an error. - `))); - process.exit(2); - } - } - - static assertPostWebpackVersion() { - if (this.isPreWebpack()) { - console.error(bold(red('\n' + stripIndents` - It seems like you're using a project generated using an old version of the Angular CLI. - The latest CLI now uses webpack and has a lot of improvements including a simpler - workflow, a faster build, and smaller bundles. - - To get more info, including a step-by-step guide to upgrade the CLI, follow this link: - https://github.com/angular/angular-cli/wiki/Upgrading-from-Beta.10-to-Beta.14 - ` + '\n'))); - process.exit(1); - } else { - // Verify that there's no build file. - if (_hasOldCliBuildFile()) { - console.error(bold(yellow('\n' + stripIndents` - It seems like you're using the newest version of the Angular CLI that uses webpack. - This version does not require an angular-cli-build file, but your project has one. - It will be ignored. - ` + '\n'))); - } - } - } - - static isPreWebpack(): boolean { - // CliConfig is a bit stricter with the schema, so we need to be a little looser with it. - const version = Version.fromProject(); - - if (version && version.isKnown()) { - if (version.major == 0) { - return true; - } else if (version.minor != 0) { - return false; - } else if (version.isBeta() && !version.toString().match(/webpack/)) { - const betaVersion = version.extra; - - if (parseInt(betaVersion) < 12) { - return true; - } - } - } else { - return _hasOldCliBuildFile(); - } - - return false; - } -} diff --git a/packages/angular-cli/utilities/INITIAL_COMMIT_MESSAGE.txt b/packages/angular-cli/utilities/INITIAL_COMMIT_MESSAGE.txt deleted file mode 100644 index 0c4e93225506..000000000000 --- a/packages/angular-cli/utilities/INITIAL_COMMIT_MESSAGE.txt +++ /dev/null @@ -1,8 +0,0 @@ -chore: initial commit from angular-cli - _ _ _ - __ _ _ __ __ _ _ _| | __ _ _ __ ___| (_) - / _ | _ \ / _ | | | | |/ _ | __|____ / __| | | - | (_| | | | | (_| | |_| | | (_| | | |_____| (__| | | - \____|_| |_|\__ |\____|_|\____|_| \___|_|_| - |___/ - \ No newline at end of file diff --git a/packages/angular-cli/utilities/ast-utils.ts b/packages/angular-cli/utilities/ast-utils.ts deleted file mode 100644 index 6adbe798d071..000000000000 --- a/packages/angular-cli/utilities/ast-utils.ts +++ /dev/null @@ -1,12 +0,0 @@ -// In order to keep refactoring low, simply export from ast-tools. -// TODO: move all dependencies of this file to ast-tools directly. -export { - getSource, - getSourceNodes, - findNodes, - insertAfterLastOccurrence, - getContentOfKeyLiteral, - getDecoratorMetadata, - addDeclarationToModule, - addProviderToModule -} from '@angular-cli/ast-tools'; diff --git a/packages/angular-cli/utilities/change.ts b/packages/angular-cli/utilities/change.ts deleted file mode 100644 index af47c4fd021a..000000000000 --- a/packages/angular-cli/utilities/change.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { - Change, - NoopChange, - MultiChange, - InsertChange, - RemoveChange, - ReplaceChange -} from '@angular-cli/ast-tools'; diff --git a/packages/angular-cli/utilities/completion.sh b/packages/angular-cli/utilities/completion.sh deleted file mode 100644 index eb4b562b8585..000000000000 --- a/packages/angular-cli/utilities/completion.sh +++ /dev/null @@ -1,85 +0,0 @@ -###-begin-ng-completion### -# -# ng command completion script -# -# Installation: ng completion 1>> ~/.bashrc 2>>&1 -# or ng completion 1>> ~/.zshrc 2>>&1 -# - -ng_opts='b build completion doc e2e g generate get github-pages:deploy gh-pages:deploy h help i init install lint make-this-awesome new s serve server set t test v version' - -build_opts='--aot --base-href --environment --i18n-file --i18n-format --locale --output-path --progress --sourcemap --suppress-sizes --target --vendor-chunk --verbose --watch --watcher -bh -dev -e -o -prod -sm -t -w' -generate_opts='class component directive enum module pipe route service c cl d e m p r s --help' -github_pages_deploy_opts='--base-href --environment --gh-token --gh-username --message --skip-build --target --user-page -bh -e -t' -help_opts='--json --verbose -v' -init_opts='--dry-run inline-style inline-template --link-cli --mobile --name --prefix --routing --skip-npm --source-dir --style --verbose -d -is -it -lc -n -p -sb -sd -sn -v' -new_opts='--directory --dry-run inline-style inline-template --link-cli --mobile --prefix --routing --skip-git --skip-npm --source-dir --style --verbose -d -dir -is -it -lc -p -sb -sd -sg -sn -v' -serve_opts='--aot --environment --hmr --host --i18n-file --i18n-format --live-reload --live-reload-base-url --live-reload-host --live-reload-live-css --live-reload-port --locale --open --port --proxy-config --sourcemap --ssl --ssl-cert --ssl-key --target --watcher -H -e -lr -lrbu -lrh -lrp -o -p -pc -sm -t -w' -set_opts='--global -g' -test_opts='--browsers --build --code-coverage --colors --lint --log-level --port --reporters --single-run --sourcemap --watch -cc -l -sm -sr -w' - -version_opts='--verbose' - -if test ".$(type -t complete 2>/dev/null || true)" = ".builtin"; then - _ng_completion() { - local cword pword opts - - COMPREPLY=() - cword=${COMP_WORDS[COMP_CWORD]} - pword=${COMP_WORDS[COMP_CWORD - 1]} - - case ${pword} in - ng) opts=$ng_opts ;; - b|build) opts=$build_opts ;; - g|generate) opts=$generate_opts ;; - gh-pages:deploy|github-pages:deploy) opts=$github_pages_deploy_opts ;; - h|help|-h|--help) opts=$help_opts ;; - init) opts=$init_opts ;; - new) opts=$new_opts ;; - s|serve|server) opts=$serve_opts ;; - set) opts=$set_opts ;; - t|test) opts=$test_opts ;; - v|version) opts=$version_opts ;; - *) opts='' ;; - esac - - COMPREPLY=( $(compgen -W '${opts}' -- $cword) ) - - return 0 - } - - complete -o default -F _ng_completion ng -elif test ".$(type -w compctl 2>/dev/null || true)" = ".compctl: builtin" ; then - _ng_completion () { - local words cword opts - read -Ac words - read -cn cword - let cword-=1 - - case $words[cword] in - ng) opts=$ng_opts ;; - b|build) opts=$build_opts ;; - g|generate) opts=$generate_opts ;; - gh-pages:deploy|github-pages:deploy) opts=$github_pages_deploy_opts ;; - h|help|-h|--help) opts=$help_opts ;; - init) opts=$init_opts ;; - new) opts=$new_opts ;; - s|serve|server) opts=$serve_opts ;; - set) opts=$set_opts ;; - t|test) opts=$test_opts ;; - v|version) opts=$version_opts ;; - *) opts='' ;; - esac - - setopt shwordsplit - reply=($opts) - unset shwordsplit - } - - compctl -K _ng_completion ng -else - echo "Shell builtin command 'complete' or 'compctl' is redefined; cannot perform ng completion." - return 1 -fi - -###-end-ng-completion### diff --git a/packages/angular-cli/utilities/dynamic-path-parser.js b/packages/angular-cli/utilities/dynamic-path-parser.js deleted file mode 100644 index e362da26f864..000000000000 --- a/packages/angular-cli/utilities/dynamic-path-parser.js +++ /dev/null @@ -1,60 +0,0 @@ -var path = require('path'); -var process = require('process'); -var fs = require('fs'); - -module.exports = function dynamicPathParser(project, entityName) { - var projectRoot = project.root; - var sourceDir = project.ngConfig.apps[0].root; - var appRoot = path.join(sourceDir, 'app'); - var cwd = process.env.PWD; - - var rootPath = path.join(projectRoot, appRoot); - - var outputPath = path.join(rootPath, entityName); - - if (entityName.indexOf(path.sep) === 0) { - outputPath = path.join(rootPath, entityName.substr(1)); - } else if (cwd.indexOf(rootPath) >= 0) { - outputPath = path.join(cwd, entityName); - } - - if (!fs.existsSync(outputPath)) { - // Verify the path exists on disk. - var parsedOutputPath = path.parse(outputPath); - var parts = parsedOutputPath.dir.split(path.sep).slice(1); - var newPath = parts.reduce((tempPath, part) => { - // if (tempPath === '') { - // return part; - // } - var withoutPlus = path.join(tempPath, path.sep, part); - var withPlus = path.join(tempPath, path.sep, '+' + part); - if (fs.existsSync(withoutPlus)) { - return withoutPlus; - } else if (fs.existsSync(withPlus)) { - return withPlus; - } - - throw `Invalid path: "${withoutPlus}"" is not a valid path.` - }, parsedOutputPath.root); - outputPath = path.join(newPath, parsedOutputPath.name); - } - - if (outputPath.indexOf(rootPath) < 0) { - throw `Invalid path: "${entityName}" cannot be ` + - `above the "${appRoot}" directory`; - } - - var adjustedPath = outputPath.replace(projectRoot, ''); - - var parsedPath = path.parse(adjustedPath); - - if (parsedPath.dir.indexOf(path.sep) === 0) { - parsedPath.dir = parsedPath.dir.substr(1); - } - - parsedPath.dir = parsedPath.dir === path.sep ? '' : parsedPath.dir; - parsedPath.appRoot = appRoot; - parsedPath.sourceDir = sourceDir; - - return parsedPath; -}; diff --git a/packages/angular-cli/utilities/find-parent-module.ts b/packages/angular-cli/utilities/find-parent-module.ts deleted file mode 100644 index 7a2273e19068..000000000000 --- a/packages/angular-cli/utilities/find-parent-module.ts +++ /dev/null @@ -1,31 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; -const SilentError = require('silent-error'); - -export default function findParentModule(project: any, currentDir: string): string { - const sourceRoot = path.join(project.root, project.ngConfig.apps[0].root, 'app'); - - // trim currentDir - currentDir = currentDir.replace(path.join(project.ngConfig.apps[0].root, 'app'), ''); - - let pathToCheck = path.join(sourceRoot, currentDir); - - while (pathToCheck.length >= sourceRoot.length) { - // TODO: refactor to not be based upon file name - const files = fs.readdirSync(pathToCheck) - .filter(fileName => !fileName.endsWith('routing.module.ts')) - .filter(fileName => fileName.endsWith('.module.ts')) - .filter(fileName => fs.statSync(path.join(pathToCheck, fileName)).isFile()); - - if (files.length === 1) { - return path.join(pathToCheck, files[0]); - } else if (files.length > 1) { - throw new SilentError(`Multiple module files found: ${pathToCheck.replace(sourceRoot, '')}`); - } - - // move to parent directory - pathToCheck = path.dirname(pathToCheck); - } - - throw new SilentError('No module files found'); -}; diff --git a/packages/angular-cli/utilities/get-dependent-files.ts b/packages/angular-cli/utilities/get-dependent-files.ts deleted file mode 100644 index 73a5cf847663..000000000000 --- a/packages/angular-cli/utilities/get-dependent-files.ts +++ /dev/null @@ -1,149 +0,0 @@ -import * as fs from 'fs'; -import * as ts from 'typescript'; -import * as glob from 'glob'; -import * as path from 'path'; -import * as denodeify from 'denodeify'; - - -const readFile = denodeify(fs.readFile); -const globSearch = denodeify(glob); - - -/** - * Interface that represents a module specifier and its position in the source file. - * Use for storing a string literal, start position and end position of ImportClause node kinds. - */ -export interface ModuleImport { - specifierText: string; - pos: number; - end: number; -} - -export interface ModuleMap { - [key: string]: ModuleImport[]; -} - -/** - * Create a SourceFile as defined by Typescript Compiler API. - * Generate a AST structure from a source file. - * - * @param fileName source file for which AST is to be extracted - */ -export function createTsSourceFile(fileName: string): Promise { - return readFile(fileName, 'utf8') - .then((contents: string) => { - return ts.createSourceFile(fileName, contents, ts.ScriptTarget.ES6, true); - }); -} - -/** - * Traverses through AST of a given file of kind 'ts.SourceFile', filters out child - * nodes of the kind 'ts.SyntaxKind.ImportDeclaration' and returns import clauses as - * ModuleImport[] - * - * @param {ts.SourceFile} node: Typescript Node of whose AST is being traversed - * - * @return {ModuleImport[]} traverses through ts.Node and returns an array of moduleSpecifiers. - */ -export function getImportClauses(node: ts.SourceFile): ModuleImport[] { - return node.statements - .filter(node => node.kind === ts.SyntaxKind.ImportDeclaration) // Only Imports. - .map((node: ts.ImportDeclaration) => { - let moduleSpecifier = node.moduleSpecifier; - return { - specifierText: moduleSpecifier.getText().slice(1, -1), - pos: moduleSpecifier.pos, - end: moduleSpecifier.end - }; - }); -} - -/** - * Find the file, 'index.ts' given the directory name and return boolean value - * based on its findings. - * - * @param dirPath - * - * @return a boolean value after it searches for a barrel (index.ts by convention) in a given path - */ -export function hasIndexFile(dirPath: string): Promise { - return globSearch(path.join(dirPath, 'index.ts'), { nodir: true }) - .then((indexFile: string[]) => { - return indexFile.length > 0; - }); -} - -/** - * Function to get all the templates, stylesheets, and spec files of a given component unit - * Assumption: When any component/service/pipe unit is generated, Angular CLI has a blueprint for - * creating associated files with the name of the generated unit. So, there are two - * assumptions made: - * a. the function only returns associated files that have names matching to the given unit. - * b. the function only looks for the associated files in the directory where the given unit - * exists. - * - * @todo read the metadata to look for the associated files of a given file. - * - * @param fileName - * - * @return absolute paths of '.html/.css/.sass/.spec.ts' files associated with the given file. - * - */ -export function getAllAssociatedFiles(fileName: string): Promise { - let fileDirName = path.dirname(fileName); - let componentName = path.basename(fileName, '.ts'); - return globSearch(path.join(fileDirName, `${componentName}.*`), { nodir: true }) - .then((files: string[]) => { - return files.filter((file) => { - return (path.basename(file) !== 'index.ts'); - }); - }); -} - -/** - * Returns a map of all dependent file/s' path with their moduleSpecifier object - * (specifierText, pos, end). - * - * @param fileName file upon which other files depend - * @param rootPath root of the project - * - * @return {Promise} ModuleMap of all dependent file/s (specifierText, pos, end) - * - */ -export function getDependentFiles(fileName: string, rootPath: string): Promise { - return globSearch(path.join(rootPath, '**/*.ts'), { nodir: true }) - .then((files: string[]) => Promise.all(files.map(file => createTsSourceFile(file))) - .then((tsFiles: ts.SourceFile[]) => tsFiles.map(file => getImportClauses(file))) - .then((moduleSpecifiers: ModuleImport[][]) => { - let allFiles: ModuleMap = {}; - files.forEach((file, index) => { - let sourcePath = path.normalize(file); - allFiles[sourcePath] = moduleSpecifiers[index]; - }); - return allFiles; - }) - .then((allFiles: ModuleMap) => { - let relevantFiles: ModuleMap = {}; - Object.keys(allFiles).forEach(filePath => { - const tempModuleSpecifiers: ModuleImport[] = allFiles[filePath] - .filter(importClause => { - // Filter only relative imports - let singleSlash = importClause.specifierText.charAt(0) === '/'; - let currentDirSyntax = importClause.specifierText.slice(0, 2) === './'; - let parentDirSyntax = importClause.specifierText.slice(0, 3) === '../'; - return singleSlash || currentDirSyntax || parentDirSyntax; - }) - .filter(importClause => { - let modulePath = path.resolve(path.dirname(filePath), importClause.specifierText); - let resolvedFileName = path.resolve(fileName); - let fileBaseName = path.basename(resolvedFileName, '.ts'); - let parsedFilePath = path.join(path.dirname(resolvedFileName), fileBaseName); - return (parsedFilePath === modulePath) || (resolvedFileName === modulePath); - }); - if (tempModuleSpecifiers.length > 0) { - relevantFiles[filePath] = tempModuleSpecifiers; - }; - }); - return relevantFiles; - })); -} diff --git a/packages/angular-cli/utilities/module-resolver.ts b/packages/angular-cli/utilities/module-resolver.ts deleted file mode 100644 index 020b25a11389..000000000000 --- a/packages/angular-cli/utilities/module-resolver.ts +++ /dev/null @@ -1,110 +0,0 @@ -'use strict'; - -import * as path from 'path'; -import * as ts from 'typescript'; -import * as dependentFilesUtils from './get-dependent-files'; - - -import {Change, ReplaceChange} from './change'; -import {NodeHost, Host} from '@angular-cli/ast-tools'; - -/** - * Rewrites import module of dependent files when the file is moved. - * Also, rewrites export module of related index file of the given file. - */ -export class ModuleResolver { - - constructor(public oldFilePath: string, public newFilePath: string, public rootPath: string) {} - - /** - * Changes are applied from the bottom of a file to the top. - * An array of Change instances are sorted based upon the order, - * then apply() method is called sequentially. - * - * @param changes {Change []} - * @param host {Host} - * @return Promise after all apply() method of Change class is called - * to all Change instances sequentially. - */ - applySortedChangePromise(changes: Change[], host: Host = NodeHost): Promise { - return changes - .sort((currentChange, nextChange) => nextChange.order - currentChange.order) - .reduce((newChange, change) => newChange.then(() => change.apply(host)), Promise.resolve()); - } - - /** - * Assesses the import specifier and determines if it is a relative import. - * - * @return {boolean} boolean value if the import specifier is a relative import. - */ - isRelativeImport(importClause: dependentFilesUtils.ModuleImport): boolean { - let singleSlash = importClause.specifierText.charAt(0) === '/'; - let currentDirSyntax = importClause.specifierText.slice(0, 2) === './'; - let parentDirSyntax = importClause.specifierText.slice(0, 3) === '../'; - return singleSlash || currentDirSyntax || parentDirSyntax; - } - - /** - * Rewrites the import specifiers of all the dependent files (cases for no index file). - * - * @todo Implement the logic for rewriting imports of the dependent files when the file - * being moved has index file in its old path and/or in its new path. - * - * @return {Promise} - */ - resolveDependentFiles(): Promise { - return dependentFilesUtils.getDependentFiles(this.oldFilePath, this.rootPath) - .then((files: dependentFilesUtils.ModuleMap) => { - let changes: Change[] = []; - let fileBaseName = path.basename(this.oldFilePath, '.ts'); - // Filter out the spec file associated with to-be-promoted component unit. - let relavantFiles = Object.keys(files).filter((file) => { - if (path.extname(path.basename(file, '.ts')) === '.spec') { - return path.basename(path.basename(file, '.ts'), '.spec') !== fileBaseName; - } else { - return true; - } - }); - relavantFiles.forEach(file => { - let tempChanges: ReplaceChange[] = files[file] - .map(specifier => { - let componentName = path.basename(this.oldFilePath, '.ts'); - let fileDir = path.dirname(file); - let changeText = path.relative(fileDir, path.join(this.newFilePath, componentName)); - if (changeText.length > 0 && changeText.charAt(0) !== '.') { - changeText = `.${path.sep}${changeText}`; - }; - let position = specifier.end - specifier.specifierText.length; - return new ReplaceChange(file, position - 1, specifier.specifierText, changeText); - }); - changes = changes.concat(tempChanges); - }); - return changes; - }); - } - - /** - * Rewrites the file's own relative imports after it has been moved to new path. - * - * @return {Promise} - */ - resolveOwnImports(): Promise { - return dependentFilesUtils.createTsSourceFile(this.oldFilePath) - .then((tsFile: ts.SourceFile) => dependentFilesUtils.getImportClauses(tsFile)) - .then(moduleSpecifiers => { - let changes: Change[] = moduleSpecifiers - .filter(importClause => this.isRelativeImport(importClause)) - .map(specifier => { - let specifierText = specifier.specifierText; - let moduleAbsolutePath = path.resolve(path.dirname(this.oldFilePath), specifierText); - let changeText = path.relative(this.newFilePath, moduleAbsolutePath); - if (changeText.length > 0 && changeText.charAt(0) !== '.') { - changeText = `.${path.sep}${changeText}`; - } - let position = specifier.end - specifier.specifierText.length; - return new ReplaceChange(this.oldFilePath, position - 1, specifierText, changeText); - }); - return changes; - }); - } -} diff --git a/packages/angular-cli/utilities/package-chunk-sort.ts b/packages/angular-cli/utilities/package-chunk-sort.ts deleted file mode 100644 index 7c4daeb6dbfa..000000000000 --- a/packages/angular-cli/utilities/package-chunk-sort.ts +++ /dev/null @@ -1,17 +0,0 @@ -export function packageChunkSort(packages: string[]) { - return function sort(left: any, right: any) { - let leftIndex = packages.indexOf(left.names[0]); - let rightindex = packages.indexOf(right.names[0]); - - if ( leftIndex < 0 || rightindex < 0) { - // Unknown packages are loaded last - return 1; - } - - if (leftIndex > rightindex) { - return 1; - } - - return -1; - }; -} diff --git a/packages/angular-cli/utilities/prerender-webpack-plugin.ts b/packages/angular-cli/utilities/prerender-webpack-plugin.ts deleted file mode 100644 index 95eb34cecea1..000000000000 --- a/packages/angular-cli/utilities/prerender-webpack-plugin.ts +++ /dev/null @@ -1,56 +0,0 @@ -// replace with the real thing when PR is merged -// https://github.com/angular/universal/pull/464 - -export interface IWebpackPrerender { - templatePath: string; - configPath: string; - appPath: string; -} - -export class PrerenderWebpackPlugin { - - private bootloader: any; - private cachedTemplate: string; - - constructor(private options: IWebpackPrerender) { - // maintain your platform instance - this.bootloader = require(this.options.configPath).getBootloader(); - } - - apply(compiler: any) { - compiler.plugin('emit', (compilation: any, callback: Function) => { - if (compilation.assets.hasOwnProperty(this.options.templatePath)) { - // we need to cache the template file to be able to re-serialize it - // even when it is not being emitted - this.cachedTemplate = compilation.assets[this.options.templatePath].source(); - } - - if (this.cachedTemplate) { - this.decacheAppFiles(); - require(this.options.configPath).serialize(this.bootloader, this.cachedTemplate) - .then((html: string) => { - compilation.assets[this.options.templatePath] = { - source: () => html, - size: () => html.length - }; - callback(); - }); - } else { - callback(); - } - }); - } - - decacheAppFiles() { - // delete all app files from cache, but keep libs - // this is needed so that the config file can reimport up to date - // versions of the app files - delete require.cache[this.options.configPath]; - Object.keys(require.cache) - .filter(key => key.startsWith(this.options.appPath)) - .forEach(function (key) { - // console.log('===', key); - delete require.cache[key]; - }); - } -}; diff --git a/packages/angular-cli/utilities/route-utils.ts b/packages/angular-cli/utilities/route-utils.ts deleted file mode 100644 index cc08ad7dd6f1..000000000000 --- a/packages/angular-cli/utilities/route-utils.ts +++ /dev/null @@ -1,11 +0,0 @@ -// In order to keep refactoring low, simply export from ast-tools. -// TODO: move all dependencies of this file to ast-tools directly. -export { - bootstrapItem, - insertImport, - addPathToRoutes, - addItemsToRouteProperties, - confirmComponentExport, - resolveComponentPath, - applyChanges -} from '@angular-cli/ast-tools'; diff --git a/packages/angular-cli/utilities/sw-install.js b/packages/angular-cli/utilities/sw-install.js deleted file mode 100644 index eadc268397fc..000000000000 --- a/packages/angular-cli/utilities/sw-install.js +++ /dev/null @@ -1,3 +0,0 @@ -// this file is used in ../models/webpack-build-mobile.ts to add -// service worker functionality to mobile projects -require('offline-plugin/runtime').install(); \ No newline at end of file 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..778ca55bb814 --- /dev/null +++ b/packages/angular/build/src/tools/i18n/i18n-inliner-worker.ts @@ -0,0 +1,581 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { type DecodedSourceMap, type SourceMapInput } from '@ampproject/remapping'; +import { + type ɵParsedTranslation, + ɵisMissingTranslationError, + ɵmakeTemplateObject, + ɵparseMessage, + ɵtranslate, +} from '@angular/localize'; +import { MagicString } from 'magic-string'; +import { deserialize } from 'node:v8'; +import { parseSync } from 'oxc-parser'; +import { traversePostOrder } from '../oxc/traversal'; +import { createSharedTranslationProxy } from './i18n-translation-reader'; +import { loadLocaleData } from './locale-data'; + +/** + * The options passed to the inliner for each code request + */ +export interface InlineCodeRequest { + /** + * The code that should be processed. + */ + code: string; + + /** + * The filename to use in error and warning messages for the provided code. + */ + filename: string; + + /** + * The locale specifier that should be used during the inlining process of the file. + */ + locale: string; + + /** + * The serialized translation messages for the locale that should be used during the inlining + * process of the file. A SharedArrayBuffer or Blob is used so that the messages are shared with + * the Worker by reference instead of being copied into it for every request. + */ + translation?: Blob | SharedArrayBuffer; + + /** + * How to handle missing translations. + */ + missingTranslation?: 'error' | 'warning' | 'ignore'; +} + +/** + * The response returned from a code request. + */ +export interface InlineCodeResult { + output: string; + messages: { type: 'error' | 'warning'; message: string }[]; +} + +/** + * The options passed to the inliner for a batch file request + */ +export interface InlineFileBatchRequest { + /** + * The filename that should be processed. + */ + filename: string; + + /** + * The file content as a Blob. + */ + code: Blob; + + /** + * Optional sourcemap content as a Blob. + */ + map?: Blob; + + /** + * The locale specifiers and optional translations to use during the inlining process of the file. + */ + locales: ReadonlyMap; + + /** + * How to handle missing translations. + */ + missingTranslation?: 'error' | 'warning' | 'ignore'; + + /** + * Whether the file data should be treated as ephemeral and not cached long-term in the Worker. + * Typically true when all remaining locales for the file are processed in a single batch. + */ + ephemeral?: boolean; + + /** + * The list of active locales in the current inlining window. Any cached translation dictionaries + * not present in this list will be evicted from the Worker's memory cache. + */ + activeLocales?: string[]; + + /** + * The current inlining generation counter. When a request with a new generation is received, + * all long-term worker caches are cleared. + */ + generation?: number; +} + +/** + * The result for a single locale within a batch file request. + */ +export interface InlineLocaleResult { + locale: string; + code?: string; + map?: string; + messages: { type: 'error' | 'warning'; message: string }[]; +} + +/** + * The response returned from a batch file request. + */ +export type InlineFileBatchResult = + | { + file: string; + unmodified: true; + messages: { type: 'error' | 'warning'; message: string }[]; + } + | { + file: string; + unmodified?: false; + results: InlineLocaleResult[]; + }; + +/** + * Cached file data including code and extracted localization metadata. + */ +interface CachedFileData { + code: string; + metadata: FileLocalizeMetadata; +} + +/** + * Cache of file data promises keyed by filename. + */ +const fileDataCache = new Map>(); + +/** + * Cache of deserialized translation messages keyed by locale. + */ +const deserializedTranslations = new Map>>(); + +/** + * The current inlining generation for this worker. + */ +let currentGeneration: number | undefined; + +let remapping: typeof import('@ampproject/remapping').default | undefined; + +/** + * Retrieves the file data for a filename, loading and extracting localization metadata. + * If `cache` is true, the result is cached in `fileDataCache` across requests in this Worker. + * If `cache` is false (ephemeral), the result is not retained in `fileDataCache`, allowing it + * to be garbage-collected once the batch request finishes. + * + * @param filename The name of the file to load. + * @param codeBlob The source code file as a Blob. + * @param cache Whether to cache the loaded file data in the Worker's long-term cache. + * @returns The cached or newly extracted code and localization metadata. + */ +function loadFileData(filename: string, codeBlob: Blob, cache = true): Promise { + const existing = fileDataCache.get(filename); + if (existing) { + if (!cache) { + fileDataCache.delete(filename); + } + + return existing; + } + + const fileDataPromise = (async () => { + const code = await codeBlob.text(); + const metadata = extractLocalizeMetadata(filename, code); + + return { code, metadata }; + })(); + + if (cache) { + fileDataPromise.catch(() => { + fileDataCache.delete(filename); + }); + fileDataCache.set(filename, fileDataPromise); + } + + return fileDataPromise; +} + +/** + * Deserializes or wraps the translation messages for a locale, reusing the result for any + * subsequent request that targets the same locale. + * @param locale The locale identifier. + * @param translation Optional serialized translation messages (SharedArrayBuffer or Blob). + * @returns The translation messages, or undefined if the locale has no translations. + */ +function loadTranslation( + locale: string, + translation?: Blob | SharedArrayBuffer, +): Promise> | undefined { + if (!translation) { + return undefined; + } + + let messagesPromise = deserializedTranslations.get(locale); + if (!messagesPromise) { + if (translation instanceof Blob) { + messagesPromise = translation + .arrayBuffer() + .then((buffer) => deserialize(new Uint8Array(buffer)) as Record) + .catch((error) => { + deserializedTranslations.delete(locale); + throw error; + }); + } else { + messagesPromise = Promise.resolve(createSharedTranslationProxy(translation)); + } + deserializedTranslations.set(locale, messagesPromise); + } + + return messagesPromise; +} + +/** + * Inlines multiple locales and translations into a JavaScript file that contains `$localize` usage. + * + * @param request An InlineFileBatchRequest object representing the options for inlining. + * @returns An object containing the inlined results for each requested locale. + */ +export async function inlineFileBatch( + request: InlineFileBatchRequest, +): Promise { + if (request.generation !== undefined && request.generation !== currentGeneration) { + currentGeneration = request.generation; + fileDataCache.clear(); + deserializedTranslations.clear(); + } + + if (request.activeLocales) { + const activeSet = new Set(request.activeLocales); + for (const locale of deserializedTranslations.keys()) { + if (!activeSet.has(locale)) { + deserializedTranslations.delete(locale); + } + } + } + + const { code, metadata } = await loadFileData(request.filename, request.code, !request.ephemeral); + + // Fast path: file has no $localize call sites or locale insert sites + if (metadata.callSites.length === 0 && metadata.localeInsertSites.length === 0) { + return { + file: request.filename, + unmodified: true, + messages: (metadata.diagnostics ?? []).map((message) => ({ + type: 'error' as const, + message, + })), + }; + } + + // Parse the sourcemap once for the entire batch if provided. + // It will naturally be garbage-collected after this batch action returns. + let map: SourceMapInput | undefined; + if (request.map) { + const rawMap = await request.map.text(); + map = rawMap ? (JSON.parse(rawMap) as SourceMapInput) : undefined; + } + + const results = []; + for (const [locale, translation] of request.locales) { + const result = await inlineLocalize( + code, + map, + metadata, + locale, + await loadTranslation(locale, translation), + request.filename, + request.missingTranslation, + ); + + results.push({ + locale, + code: result.code, + map: result.map, + messages: result.diagnostics, + }); + } + + return { + file: request.filename, + results, + }; +} + +/** + * Inlines the provided locale and translation into JavaScript code that contains `$localize` usage. + * This function is a secondary entry primarily for use with component HMR update modules. + * + * @param request An InlineRequest object representing the options for inlining + * @returns An object containing the inlined code. + */ +export async function inlineCode(request: InlineCodeRequest): Promise { + const metadata = extractLocalizeMetadata(request.filename, request.code); + const result = await inlineLocalize( + request.code, + undefined, + metadata, + request.locale, + await loadTranslation(request.locale, request.translation), + request.filename, + request.missingTranslation, + ); + + return { + output: result.code ?? request.code, + messages: result.diagnostics, + }; +} + +type DiagnosticMessage = { type: 'error' | 'warning'; message: string }; + +/** + * Translates a $localize message using @angular/localize low-level runtime functions. + * Handles missing translations and errors without requiring @angular/localize/tools. + */ +function translateMessage( + diagnostics: DiagnosticMessage[], + translations: Record | undefined, + messageParts: TemplateStringsArray, + substitutions: readonly number[], + missingTranslation: 'error' | 'warning' | 'ignore', +): [TemplateStringsArray, readonly number[]] { + // Fast path: untranslated locale (e.g. source locale without a translation dictionary). + // Directly parse the message parts to strip metadata and placeholder markers without + // throwing and catching a MissingTranslationError exception. + if (translations === undefined) { + const message = ɵparseMessage(messageParts, substitutions); + + return [ɵmakeTemplateObject(message.messageParts, message.messageParts), substitutions]; + } + + try { + return ɵtranslate(translations, messageParts, substitutions) as [ + TemplateStringsArray, + readonly number[], + ]; + } catch (error) { + if (ɵisMissingTranslationError(error)) { + if (missingTranslation !== 'ignore') { + diagnostics.push({ type: missingTranslation, message: error.message }); + } + + return [ + ɵmakeTemplateObject(error.parsedMessage.messageParts, error.parsedMessage.messageParts), + substitutions, + ]; + } else { + const message = error instanceof Error ? error.message : String(error); + diagnostics.push({ type: 'error', message }); + + return [messageParts, substitutions]; + } + } +} + +/** + * Metadata for a `$localize` tagged template expression extracted from the AST. + */ +interface LocalizeCallSite { + start: number; + end: number; + messageParts: TemplateStringsArray; + expressions: { start: number; end: number }[]; + expressionIndexes: number[]; +} + +/** + * Metadata extracted from a JavaScript file AST needed for localization inlining. + */ +interface FileLocalizeMetadata { + callSites: LocalizeCallSite[]; + localeInsertSites: { start: number; end: number }[]; + diagnostics?: string[]; +} + +/** + * Extracts localization call sites and locale insertion points from JavaScript code using OXC. + * + * @param filename The name of the file being processed. + * @param code The JavaScript source code. + * @returns The extracted localization metadata. + */ +function extractLocalizeMetadata(filename: string, code: string): FileLocalizeMetadata { + const { program } = parseSync(filename, code, { + sourceType: 'unambiguous', + }); + + if (!program) { + throw new Error(`Unknown error occurred parsing file "${filename}" with OXC.`); + } + + const callSites: LocalizeCallSite[] = []; + const localeInsertSites: { start: number; end: number }[] = []; + let diagnostics: string[] | undefined; + + traversePostOrder(program, (node) => { + if (node.type === 'Literal') { + if (typeof node.value === 'string' && node.value === '___NG_LOCALE_INSERT___') { + localeInsertSites.push({ start: node.start, end: node.end }); + } + } else if (node.type === 'TaggedTemplateExpression') { + if (node.tag.type === 'Identifier' && node.tag.name === '$localize') { + const cooked: string[] = []; + const raw: string[] = []; + let hasMalformedEscape = false; + + for (const q of node.quasi.quasis) { + if (q.value.cooked === null || q.value.cooked === undefined) { + hasMalformedEscape = true; + (diagnostics ??= []).push( + `Malformed escape sequence in $localize template literal in file "${filename}".`, + ); + break; + } + cooked.push(q.value.cooked); + raw.push(q.value.raw); + } + + if (!hasMalformedEscape) { + const messageParts = Object.assign(cooked, { raw }); + const expressions = node.quasi.expressions.map((expr) => ({ + start: expr.start, + end: expr.end, + })); + const expressionIndexes = expressions.map((_, index) => index); + + callSites.push({ + start: node.start, + end: node.end, + messageParts, + expressions, + expressionIndexes, + }); + } + } + } + }); + + return { callSites, localeInsertSites, diagnostics }; +} + +/** + * Escapes a template literal string part for insertion into an ES template literal (backticks). + * Uses JSON.stringify for base escaping of control characters and backslashes, then unescapes + * double quotes and escapes backticks and `${` expression delimiters in a single pass. + */ +function escapeTemplatePart(part: string): string { + return JSON.stringify(part) + .slice(1, -1) + .replace(/\\"|`|\$\{/g, (match) => (match === '\\"' ? '"' : '\\' + match)); +} + +/** + * Inlines translations into code using previously extracted localization metadata. + * + * @param code The source code to transform. + * @param map Optional source map for the source code. + * @param metadata Extracted localization metadata. + * @param locale The target locale identifier. + * @param translation The translation messages dictionary, or undefined for untranslated locale. + * @param filename The name of the file being transformed. + * @param missingTranslation How to handle missing translations. + * @returns The transformed code, optional remapped source map, and diagnostics. + */ +async function inlineLocalize( + code: string, + map: SourceMapInput | undefined, + metadata: FileLocalizeMetadata, + locale: string, + translation: Record | undefined, + filename: string, + missingTranslation: 'error' | 'warning' | 'ignore' = 'warning', +) { + const magicString = new MagicString(code); + const diagnostics: DiagnosticMessage[] = []; + + if (metadata.diagnostics) { + for (const message of metadata.diagnostics) { + diagnostics.push({ type: 'error', message }); + } + } + + if (metadata.localeInsertSites.length > 0) { + const localeData = await loadLocaleData(locale); + if (localeData.error) { + diagnostics.push({ type: 'error', message: localeData.error }); + } else if (localeData.warning) { + diagnostics.push({ type: 'warning', message: localeData.warning }); + } + let injected = false; + for (const site of metadata.localeInsertSites) { + magicString.overwrite( + site.start, + site.end, + JSON.stringify(locale) + (localeData.code && !injected ? `;\n${localeData.code}\n;` : ''), + ); + injected = true; + } + } + + for (const callSite of metadata.callSites) { + const [translatedParts, translatedSubstitutions] = translateMessage( + diagnostics, + translation, + callSite.messageParts, + callSite.expressionIndexes, + translation === undefined ? 'ignore' : missingTranslation, + ); + + // Reconstruct the new template/string literal replacement + let replacement: string; + if (translatedSubstitutions.length === 0) { + replacement = JSON.stringify(translatedParts[0]); + } else { + replacement = '`'; + for (let i = 0; i < translatedParts.length; i++) { + replacement += escapeTemplatePart(translatedParts[i]); + + if (i < translatedSubstitutions.length) { + const originalIndex = translatedSubstitutions[i]; + const expr = callSite.expressions[originalIndex]; + const exprCode = magicString.slice(expr.start, expr.end); + replacement += '${' + exprCode + '}'; + } + } + replacement += '`'; + } + + magicString.overwrite(callSite.start, callSite.end, replacement); + } + + if (!magicString.hasChanged()) { + return { + code: undefined, + map: undefined, + diagnostics, + }; + } + + const outputCode = magicString.toString(); + let outputMap; + if (map) { + // A decoded map is generated here rather than an encoded one because remapping decodes its + // inputs. Encoding the mappings only for remapping to immediately decode them again doubles + // the peak memory of the largest structure involved in inlining a file. + const rawMap = magicString.generateDecodedMap({ + source: filename, + includeContent: true, + hires: 'boundary', + }); + remapping ??= (await import('@ampproject/remapping')).default; + outputMap = remapping([{ ...rawMap, version: 3 } satisfies DecodedSourceMap, map], () => null); + } + + return { + code: outputCode, + map: outputMap && JSON.stringify(outputMap), + diagnostics, + }; +} diff --git a/packages/angular/build/src/tools/i18n/i18n-inliner.ts b/packages/angular/build/src/tools/i18n/i18n-inliner.ts new file mode 100644 index 000000000000..d4a4c4cd631f --- /dev/null +++ b/packages/angular/build/src/tools/i18n/i18n-inliner.ts @@ -0,0 +1,749 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { ɵParsedTranslation } from '@angular/localize'; +import assert from 'node:assert'; +import { createRequire } from 'node:module'; +import { extname, join } from 'node:path'; +import { serialize } from 'node:v8'; +import { + type Cache, + type PersistentCacheStore, + createPersistentCacheStore, +} from '../../utils/cache'; +import { calculateHash, createContentHash, initializeHash } from '../../utils/hash'; +import { WorkerPool } from '../../utils/worker-pool'; +import { + type BuildOutputFile, + BuildOutputFileType, + createOutputFile, +} from '../esbuild/bundler-files'; +import type { + InlineCodeRequest, + InlineCodeResult, + InlineFileBatchRequest, + InlineFileBatchResult, +} from './i18n-inliner-worker'; +import { encodeTranslationToBuffer } from './i18n-translation-encoder'; + +// TODO: Convert to import.meta usage during ESM transition +const localRequire = createRequire(__filename); +const INLINER_WORKER_PATH = localRequire.resolve('./i18n-inliner-worker'); + +interface WorkerTaskMap { + inlineFileBatch: { + request: InlineFileBatchRequest; + result: InlineFileBatchResult; + }; + inlineCode: { + request: InlineCodeRequest; + result: InlineCodeResult; + }; +} + +/** + * A keyword used to indicate if a JavaScript file may require inlining of translations. + * This keyword is used to avoid processing files that would not otherwise need i18n processing. + */ +const LOCALIZE_KEYWORD = '$localize'; + +/** + * The baseline number of locales to process concurrently in a single sliding window. + * This caps peak worker memory on low-core machines while maintaining multi-locale batching throughput. + */ +const DEFAULT_LOCALE_WINDOW_SIZE = 8; + +/** + * Minimum byte size threshold for a file to be eligible for multi-batch sharding. + * Files below this threshold (< 100 KB) are processed in a single batch to minimize IPC overhead. + */ +const SMALL_FILE_FLOOR_BYTES = 100 * 1024; + +/** + * Ratio of the maximum file size in a window to consider a file "dominant". + * Files within 70% of the largest file are sharded across bounded workers for concurrency. + */ +const DOMINANT_FILE_RATIO = 0.7; + +/** + * Maximum number of worker threads to allocate concurrently across dominant files. + * Capping concurrency on dominant files (which hold large AST and sourcemap trees) + * prevents V8 heap and RSS explosion on machines with high CPU core counts. + */ +const MAX_DOMINANT_WORKERS = 4; + +/** + * Serializes the translation messages for a locale for transfer to an inliner Worker. + * + * A SharedArrayBuffer is preferred because it enables zero-copy shared memory access + * and on-demand string decoding across all worker threads. Falls back to a Blob when + * SharedArrayBuffer is unavailable. + * + * @param translation The translation messages for a locale, if the locale has any. + * @param translationIntegrity Optional content hash of the translation file for cache lookup. + * @param translationCache Optional Cache instance for binary translation buffers. + * @returns A SharedArrayBuffer or Blob containing the serialized messages, or undefined if none. + */ +async function serializeTranslation( + translation: Record | undefined, + translationIntegrity?: string, + translationCache?: Cache, +): Promise { + if (!translation) { + return undefined; + } + + if (typeof SharedArrayBuffer !== 'undefined') { + if (translationIntegrity && translationCache) { + // Look up or generate binary translation data in the persistent cache. + // A Uint8Array view is stored in the cache store to allow binary persistence. + const binaryData = await translationCache.getOrCreate(translationIntegrity, () => { + return new Uint8Array(encodeTranslationToBuffer(translation)); + }); + + // On a cache miss, getOrCreate returns the newly created Uint8Array backed by the + // original SharedArrayBuffer. Return it directly to avoid an unnecessary allocation and copy. + if ( + binaryData.buffer instanceof SharedArrayBuffer && + binaryData.byteOffset === 0 && + binaryData.byteLength === binaryData.buffer.byteLength + ) { + return binaryData.buffer; + } + + // On a warm cache hit, the restored data is backed by a standard ArrayBuffer from disk. + // Copy it into a SharedArrayBuffer so worker threads can access it via zero-copy shared memory. + const buffer = new SharedArrayBuffer(binaryData.byteLength); + new Uint8Array(buffer).set(binaryData); + + return buffer; + } + + // When persistent caching is not configured, encode directly into a SharedArrayBuffer. + return encodeTranslationToBuffer(translation); + } + + return new Blob([serialize(translation)]); +} + +/** + * Inlining options that should apply to all transformed code. + */ +export interface I18nInlinerOptions { + missingTranslation: 'error' | 'warning' | 'ignore'; + + /** + * The maximum number of concurrent translation inlining operations. + * When omitted, concurrency defaults to the available worker pool threads. + */ + maxConcurrency?: number; + persistentCachePath?: string; + localizeVersion?: string; +} + +/** + * Options for inlining a specific locale. + */ +export interface LocaleInlineOptions { + /** + * The locale specifier string. + */ + locale: string; + + /** + * The translation messages for the locale, or undefined for the source/untranslated locale. + */ + translation?: Record; + + /** + * An optional content integrity hash of the translation file(s) for fast cache key calculation. + */ + translationIntegrity?: string; +} + +/** + * Result of inlining for a specific locale. + */ +export interface LocaleInlineResult { + outputFiles: BuildOutputFile[]; + errors: string[]; + warnings: string[]; +} + +/** + * Transformation result for a single file and locale combination. + */ +interface TransformedFileResult { + file: string; + code?: string; + map?: string; + messages: { type: 'error' | 'warning'; message: string }[]; +} + +/** + * An uncached transformation request entry for a file within a specific locale. + */ +interface UncachedLocaleEntry { + locale: string; + cacheKey?: string; + translation?: Blob | SharedArrayBuffer; +} + +/** + * A class that performs i18n translation inlining of JavaScript code. + * A worker pool is used to distribute the transformation actions and allow + * parallel processing. Inlining is only performed on code that contains the + * localize function (`$localize`). + */ +export class I18nInliner { + #cacheInitFailed = false; + readonly #workerPool: WorkerPool; + readonly #ownsWorkerPool: boolean; + #cacheStore: PersistentCacheStore | undefined; + #transformedFileCache: Cache | undefined; + #translationCache: Cache | undefined; + #generation = 0; + + get #maxConcurrency(): number { + return this.options.maxConcurrency ?? (this.#workerPool.maxThreads || 1); + } + + constructor( + private readonly options: I18nInlinerOptions, + workerPool?: WorkerPool, + ) { + if ( + options.maxConcurrency !== undefined && + (!Number.isInteger(options.maxConcurrency) || options.maxConcurrency < 1) + ) { + throw new RangeError('options.maxConcurrency must be an integer greater than or equal to 1.'); + } + + this.#ownsWorkerPool = !workerPool; + // Piscina uses object spread against default options internally. Only define + // maxThreads when specified to avoid overwriting Piscina's default thread count + // with undefined. + this.#workerPool = + workerPool ?? + new WorkerPool({ + ...(options.maxConcurrency !== undefined && { maxThreads: options.maxConcurrency }), + }); + } + + #partitionFiles(files: Iterable): { + filenames: string[]; + localizeFiles: Map; + localizeMaps: Map; + unmodifiedFiles: BuildOutputFile[]; + } { + const filenames: string[] = []; + const localizeFiles = new Map(); + const localizeMaps = new Map(); + const unmodifiedFiles: BuildOutputFile[] = []; + + const pendingMaps: BuildOutputFile[] = []; + for (const file of files) { + if (file.type === BuildOutputFileType.Root || file.type === BuildOutputFileType.ServerRoot) { + // Skip also the server entry-point. + // Skip stats and similar files. + continue; + } + + const fileExtension = extname(file.path); + if (fileExtension === '.js' || fileExtension === '.mjs') { + // Check if localizations are present + const contentBuffer = Buffer.isBuffer(file.contents) + ? file.contents + : Buffer.from(file.contents.buffer, file.contents.byteOffset, file.contents.byteLength); + const hasLocalize = contentBuffer.includes(LOCALIZE_KEYWORD); + + if (hasLocalize) { + localizeFiles.set(file.path, file); + filenames.push(file.path); + + continue; + } + } else if (fileExtension === '.map') { + // The related JS file may not have been checked yet. To ensure that map files are not + // missed, store any pending map files and check them after all output files. + pendingMaps.push(file); + continue; + } + + unmodifiedFiles.push(file); + } + + // Check if any pending map files should be processed by checking if the parent JS file is present + for (const file of pendingMaps) { + const jsPath = file.path.slice(0, -4); + if (localizeFiles.has(jsPath)) { + localizeMaps.set(jsPath, file); + } else { + unmodifiedFiles.push(file); + } + } + + return { filenames, localizeFiles, localizeMaps, unmodifiedFiles }; + } + + /** + * Performs inlining of translations across multiple locales in parallel. + * + * An adaptive 2D task-partitioning algorithm distributes (files x locales) work units + * across all worker threads while caching AST metadata and sourcemaps in worker memory. + * + * @param files The build output files to transform. + * @param locales The locales and translations to inline. + * @returns A map of locale names to their inlined output files and diagnostics. + */ + async inlineAll( + files: Iterable, + locales: Iterable, + ): Promise> { + await this.initCache(); + + const generation = ++this.#generation; + const { missingTranslation, localizeVersion } = this.options; + const localeList = Array.from(locales); + + if (localeList.length === 0) { + return new Map(); + } + + const { filenames, localizeFiles, localizeMaps, unmodifiedFiles } = this.#partitionFiles(files); + + const fileResultsByLocale = new Map>(); + for (const { locale } of localeList) { + assert(!fileResultsByLocale.has(locale), 'Duplicate locale provided to inliner: ' + locale); + fileResultsByLocale.set(locale, new Map()); + } + + // Process locales in sliding windows to cap peak worker memory. + // Ensure the window has at least enough locales to saturate all available workers on high-core machines. + const windowSize = Math.max(DEFAULT_LOCALE_WINDOW_SIZE, this.#maxConcurrency); + for (let i = 0; i < localeList.length; i += windowSize) { + const windowLocales = localeList.slice(i, i + windowSize); + const activeLocales = windowLocales.map((item) => item.locale); + const isLastWindow = i + windowSize >= localeList.length; + + // Pre-calculate cache key bases and serialized Blobs for each locale in this window + const localeCacheBases = new Map(); + const localeBlobs = new Map(); + + await Promise.all( + windowLocales.map(async ({ locale, translation, translationIntegrity }) => { + const serialized = await serializeTranslation( + translation, + translationIntegrity, + this.#translationCache, + ); + localeBlobs.set(locale, serialized); + + if (this.#cacheStore) { + localeCacheBases.set( + locale, + calculateHash( + JSON.stringify({ + locale, + translation: translationIntegrity || translation, + missingTranslation, + localizeVersion, + }), + ), + ); + } + }), + ); + + const uncachedByFile = new Map(); + + if (this.#transformedFileCache) { + const cache = this.#transformedFileCache; + const cacheChecks: Promise[] = []; + + for (const filename of filenames) { + const file = localizeFiles.get(filename); + assert(file !== undefined, 'Localize file must exist: ' + filename); + + const fileEntriesPromises = windowLocales.map( + async ({ locale }): Promise => { + const fileCacheKeyBase = localeCacheBases.get(locale); + assert(fileCacheKeyBase !== undefined, 'Cache base must exist for locale: ' + locale); + + const hasher = createContentHash(); + hasher.update(file.hash); + hasher.update(filename); + hasher.update(fileCacheKeyBase); + const cacheKey = hasher.digest(); + + try { + const result = await cache.get(cacheKey); + if (result) { + fileResultsByLocale.get(locale)?.set(filename, result); + + return; + } + } catch {} + + return { + locale, + cacheKey, + translation: localeBlobs.get(locale), + }; + }, + ); + + cacheChecks.push( + Promise.all(fileEntriesPromises).then((entries) => { + const filtered = entries.filter((e): e is UncachedLocaleEntry => e !== undefined); + if (filtered.length > 0) { + uncachedByFile.set(filename, filtered); + } + }), + ); + } + + await Promise.all(cacheChecks); + } else { + for (const filename of filenames) { + uncachedByFile.set( + filename, + windowLocales.map(({ locale }) => ({ + locale, + translation: localeBlobs.get(locale), + })), + ); + } + } + + // Adaptive 2D Sharding for uncached tasks in this window + if (uncachedByFile.size > 0) { + await this.#processUncachedBatches( + localizeFiles, + localizeMaps, + uncachedByFile, + fileResultsByLocale, + activeLocales, + isLastWindow, + generation, + ); + } + } + + // Assemble final results in deterministic order per locale + const resultsByLocale = new Map(); + + for (const { locale } of localeList) { + const fileResults = fileResultsByLocale.get(locale); + const outputFiles: BuildOutputFile[] = []; + const errors: string[] = []; + const warnings: string[] = []; + + if (fileResults) { + for (const filename of filenames) { + const originalFile = localizeFiles.get(filename); + assert(originalFile !== undefined, 'Localize file must exist: ' + filename); + + const fileResult = fileResults.get(filename); + if (!fileResult) { + continue; + } + + const type = originalFile.type; + if (fileResult.code != undefined) { + outputFiles.push(createOutputFile(filename, fileResult.code, type)); + } else { + outputFiles.push(originalFile.clone()); + } + + const originalMap = localizeMaps.get(filename); + if (fileResult.map !== undefined) { + outputFiles.push(createOutputFile(filename + '.map', fileResult.map, type)); + } else if (originalMap !== undefined) { + outputFiles.push(originalMap.clone()); + } + + for (const message of fileResult.messages) { + if (message.type === 'error') { + errors.push(message.message); + } else { + warnings.push(message.message); + } + } + } + } + + // Include cloned unmodified files for every locale + outputFiles.push(...unmodifiedFiles.map((file) => file.clone())); + + resultsByLocale.set(locale, { + outputFiles, + errors, + warnings, + }); + } + + return resultsByLocale; + } + + async #processUncachedBatches( + localizeFiles: Map, + localizeMaps: Map, + uncachedByFile: Map, + fileResultsByLocale: Map>, + activeLocales?: string[], + isLastWindow = true, + generation?: number, + ): Promise { + const workerCount = this.#maxConcurrency; + + // Extract file data and identify the heaviest file size in a single pass + let maxFileSize = 0; + const sortedFiles = Array.from(uncachedByFile, ([filename, entries]) => { + const codeFile = localizeFiles.get(filename); + assert(codeFile !== undefined, 'Localize file must exist: ' + filename); + const fileSize = codeFile.contents.byteLength; + if (fileSize > maxFileSize) { + maxFileSize = fileSize; + } + + return { filename, entries, codeFile, fileSize }; + }); + + // Sort files descending by byte size (Longest Processing Time First / LPT). + // Heavy files (e.g. main.js) are queued first to saturate all worker threads immediately, + // while small files act as gap fillers near the window barrier to prevent tail stragglers. + sortedFiles.sort((a, b) => b.fileSize - a.fileSize); + + const dominantThreshold = Math.max(SMALL_FILE_FLOOR_BYTES, maxFileSize * DOMINANT_FILE_RATIO); + let dominantCount = 0; + for (const { fileSize } of sortedFiles) { + if (fileSize >= dominantThreshold) { + dominantCount++; + } else { + break; + } + } + const dominantTargetWorkers = + dominantCount > 0 + ? Math.max(1, Math.min(workerCount - 1, Math.floor(MAX_DOMINANT_WORKERS / dominantCount))) + : 1; + + const workerTasks: Promise[] = []; + + for (const { filename, entries, codeFile, fileSize } of sortedFiles) { + const mapFile = localizeMaps.get(filename); + const codeBlob = new Blob([codeFile.contents]); + const mapBlob = mapFile ? new Blob([mapFile.contents]) : undefined; + + let localesPerBatch: number; + if (uncachedByFile.size === 1) { + // Single file in window: shard across bounded workers to balance throughput and peak memory + const targetWorkers = Math.min(workerCount, MAX_DOMINANT_WORKERS); + localesPerBatch = Math.max(1, Math.ceil(entries.length / targetWorkers)); + } else if (fileSize < SMALL_FILE_FLOOR_BYTES) { + // Small chunks (< 100 KB): process all locales in 1 batch to eliminate IPC overhead + localesPerBatch = entries.length; + } else if (fileSize >= dominantThreshold) { + // Dominant file(s) with multiple files: shard across bounded workers while always reserving + // at least one worker to process smaller chunks in parallel without starvation. + localesPerBatch = Math.max(1, Math.ceil(entries.length / dominantTargetWorkers)); + } else if (uncachedByFile.size >= workerCount) { + // Worker pool is already saturated by file-level concurrency; + // process all locales in a single batch to avoid duplicate parsing and IPC overhead. + localesPerBatch = entries.length; + } else { + // Intermediate files with idle workers: moderate sharding + localesPerBatch = Math.max(1, Math.ceil(entries.length / 2)); + } + + const ephemeral = isLastWindow && entries.length <= localesPerBatch; + for (let i = 0; i < entries.length; i += localesPerBatch) { + const batchEntries = entries.slice(i, i + localesPerBatch); + const task = (async () => { + const batchResult = await this.#runWorkerTask('inlineFileBatch', { + filename, + code: codeBlob, + map: mapBlob, + locales: new Map(batchEntries.map((e) => [e.locale, e.translation])), + missingTranslation: this.options.missingTranslation, + ephemeral, + activeLocales, + generation, + }); + + if (batchResult.unmodified) { + const unmodifiedResult: TransformedFileResult = { + file: filename, + messages: batchResult.messages, + }; + + const cachePromises: Promise[] = []; + for (const { locale, cacheKey } of batchEntries) { + fileResultsByLocale.get(locale)?.set(filename, unmodifiedResult); + + if (this.#transformedFileCache && cacheKey) { + cachePromises.push(this.#transformedFileCache.put(cacheKey, unmodifiedResult)); + } + } + await Promise.allSettled(cachePromises); + } else { + const cachePromises: Promise[] = []; + for (const res of batchResult.results) { + const matchingEntry = batchEntries.find((e) => e.locale === res.locale); + const cacheKey = matchingEntry?.cacheKey; + const fileResult: TransformedFileResult = { + file: filename, + code: res.code, + map: res.map, + messages: res.messages, + }; + + if (this.#transformedFileCache && cacheKey) { + cachePromises.push(this.#transformedFileCache.put(cacheKey, fileResult)); + } + + fileResultsByLocale.get(res.locale)?.set(filename, fileResult); + } + await Promise.allSettled(cachePromises); + } + })(); + + workerTasks.push(task); + } + } + + await Promise.all(workerTasks); + } + + #runWorkerTask( + name: T, + request: WorkerTaskMap[T]['request'], + ): Promise { + return this.#workerPool.run(request, { + filename: INLINER_WORKER_PATH, + name, + }) as Promise; + } + + /** + * Performs inlining of translations for the provided locale and translations. + * + * @param files The build output files to transform. + * @param locale The string representing the locale to inline. + * @param translation The translation messages to use when inlining. + * @param translationIntegrity An optional integrity value for the translation messages to use for caching. + * @returns A promise that resolves to an array of OutputFiles representing a translated result. + */ + async inlineForLocale( + files: Iterable, + locale: string, + translation: Record | undefined, + translationIntegrity?: string, + ): Promise { + const results = await this.inlineAll(files, [{ locale, translation, translationIntegrity }]); + const result = results.get(locale); + assert(result !== undefined, `Result for locale '${locale}' should be present.`); + + return result; + } + + async inlineTemplateUpdate( + locale: string, + translation: Record | undefined, + templateCode: string, + templateId: string, + translationIntegrity?: string, + ): Promise<{ code: string; errors: string[]; warnings: string[] }> { + const hasLocalize = templateCode.includes(LOCALIZE_KEYWORD); + + if (!hasLocalize) { + return { + code: templateCode, + errors: [], + warnings: [], + }; + } + + const { output, messages } = await this.#runWorkerTask('inlineCode', { + code: templateCode, + filename: templateId, + locale, + missingTranslation: this.options.missingTranslation, + translation: await serializeTranslation( + translation, + translationIntegrity, + this.#translationCache, + ), + }); + + const errors: string[] = []; + const warnings: string[] = []; + for (const message of messages) { + if (message.type === 'error') { + errors.push(message.message); + } else { + warnings.push(message.message); + } + } + + return { + code: output, + errors, + warnings, + }; + } + + /** + * Stops all active transformation tasks and shuts down all workers. + * @returns A void promise that resolves when closing is complete. + */ + async close(): Promise { + await Promise.allSettled([ + this.#cacheStore?.close(), + this.#ownsWorkerPool ? this.#workerPool.destroy() : undefined, + ]); + } + + /** + * Initializes the cache for storing translated bundles. + * If the cache is already initialized, it does nothing. + * + * @returns A promise that resolves once the cache initialization process is complete. + */ + private async initCache(): Promise { + if (this.#cacheStore || this.#cacheInitFailed) { + return; + } + + const { persistentCachePath } = this.options; + // Webcontainers currently do not support this persistent cache store. + if (!persistentCachePath || process.versions.webcontainer) { + return; + } + + // Initialize a persistent cache for i18n transformations. + try { + const [, cacheStore] = await Promise.all([ + initializeHash(), + createPersistentCacheStore(join(persistentCachePath, 'angular-i18n')), + ]); + this.#cacheStore = cacheStore; + this.#transformedFileCache = cacheStore.createCache('transforms'); + this.#translationCache = cacheStore.createCache('translations'); + } catch { + this.#cacheInitFailed = true; + + // eslint-disable-next-line no-console + console.warn( + 'Unable to initialize JavaScript cache storage.\n' + + 'This will not affect the build output content but may result in slower builds.', + ); + } + } +} diff --git a/packages/angular/build/src/tools/i18n/i18n-inliner_spec.ts b/packages/angular/build/src/tools/i18n/i18n-inliner_spec.ts new file mode 100644 index 000000000000..2fbb6e02ce31 --- /dev/null +++ b/packages/angular/build/src/tools/i18n/i18n-inliner_spec.ts @@ -0,0 +1,1187 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { ɵParsedTranslation } from '@angular/localize'; +import { transform } from 'esbuild'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { createPersistentCacheStore } from '../../utils/cache'; +import { initializeHash } from '../../utils/hash'; +import { WorkerPool } from '../../utils/worker-pool'; +import { + type BuildOutputFile, + BuildOutputFileType, + createOutputFile, +} from '../esbuild/bundler-files'; +import { I18nInliner, type I18nInlinerOptions } from './i18n-inliner'; + +/** + * A module that uses a `$localize` message with an explicit message identifier so that the + * translations for a test can be keyed by a known name. + */ +const GREETING_SOURCE = 'export const greeting = $localize`:@@greeting:Hello`;\n'; + +/** + * Creates the parsed translation form that `@angular/localize` expects. + */ +function parsedTranslation( + parts: string[], + placeholderNames: string[] = [], + text?: string, +): ɵParsedTranslation { + return { + messageParts: Object.assign([...parts], { raw: [...parts] }), + placeholderNames, + text: text ?? parts.join(''), + }; +} + +function translationFor(message: string): ɵParsedTranslation { + return parsedTranslation([message], [], message); +} + +function browserFile(path: string, contents: string): BuildOutputFile { + return createOutputFile(path, contents, BuildOutputFileType.Browser); +} + +function findFile(outputFiles: BuildOutputFile[], path: string): BuildOutputFile { + const file = outputFiles.find((output) => output.path === path); + if (!file) { + throw new Error(`Expected output files to contain '${path}'.`); + } + + return file; +} + +describe('I18nInliner', () => { + let inliner: I18nInliner | undefined; + + beforeAll(async () => { + await initializeHash(); + }); + + // A single thread is used throughout so that every file of every locale is inlined by the same + // Worker. Any translation state that a Worker retains between requests is then observable. + function createInliner(options?: Partial, maxConcurrency = 1): I18nInliner { + inliner = new I18nInliner({ missingTranslation: 'warning', maxConcurrency, ...options }); + + return inliner; + } + + afterEach(async () => { + await inliner?.close(); + inliner = undefined; + }); + + it('inlines the translations of a locale', async () => { + const { outputFiles, errors, warnings } = await createInliner().inlineForLocale( + [browserFile('main.js', GREETING_SOURCE)], + 'fr', + { greeting: translationFor('Bonjour') }, + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toContain('"Bonjour"'); + expect(findFile(outputFiles, 'main.js').text).not.toContain('$localize'); + }); + + it('inlines the translations of each locale when several are inlined in sequence', async () => { + const files = [browserFile('main.js', GREETING_SOURCE)]; + const localeInliner = createInliner(); + + const french = await localeInliner.inlineForLocale(files, 'fr', { + greeting: translationFor('Bonjour'), + }); + const german = await localeInliner.inlineForLocale(files, 'de', { + greeting: translationFor('Hallo'), + }); + // Repeats the first locale to cover a locale being inlined again after another has been. + const frenchAgain = await localeInliner.inlineForLocale(files, 'fr', { + greeting: translationFor('Bonjour'), + }); + + expect(findFile(french.outputFiles, 'main.js').text).toContain('"Bonjour"'); + expect(findFile(german.outputFiles, 'main.js').text).toContain('"Hallo"'); + expect(findFile(frenchAgain.outputFiles, 'main.js').text).toContain('"Bonjour"'); + }); + + it('inlines the translations of a locale into every file that uses them', async () => { + const { outputFiles } = await createInliner().inlineForLocale( + [browserFile('main.js', GREETING_SOURCE), browserFile('chunk.js', GREETING_SOURCE)], + 'fr', + { greeting: translationFor('Bonjour') }, + ); + + expect(findFile(outputFiles, 'main.js').text).toContain('"Bonjour"'); + expect(findFile(outputFiles, 'chunk.js').text).toContain('"Bonjour"'); + }); + + it('retains the original messages for a locale without translations', async () => { + const { outputFiles, errors, warnings } = await createInliner().inlineForLocale( + [browserFile('main.js', GREETING_SOURCE)], + 'en-US', + undefined, + ); + + // A locale without translations is the source locale, so its messages are not missing. + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toContain('"Hello"'); + }); + + it('retains the original messages with placeholders for a locale without translations', async () => { + const source = 'export const greeting = $localize`:@@greeting:Hello ${name}:NAME:!`;\n'; + const { outputFiles, errors, warnings } = await createInliner().inlineForLocale( + [browserFile('main.js', source)], + 'en-US', + undefined, + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toContain('`Hello ${name}!`'); + expect(findFile(outputFiles, 'main.js').text).not.toContain('$localize'); + }); + + it('warns and retains the original message when a locale is missing a translation', async () => { + const { outputFiles, errors, warnings } = await createInliner().inlineForLocale( + [browserFile('main.js', GREETING_SOURCE)], + 'fr', + { unrelated: translationFor('Sans rapport') }, + ); + + expect(errors).toEqual([]); + expect(warnings.length).toBe(1); + expect(warnings[0]).toContain('greeting'); + expect(findFile(outputFiles, 'main.js').text).toContain('"Hello"'); + }); + + it('errors and retains the original message when missingTranslation is "error"', async () => { + const { outputFiles, errors, warnings } = await createInliner({ + missingTranslation: 'error', + }).inlineForLocale([browserFile('main.js', GREETING_SOURCE)], 'fr', { + unrelated: translationFor('Sans rapport'), + }); + + expect(errors.length).toBe(1); + expect(errors[0]).toContain('greeting'); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toContain('"Hello"'); + }); + + it('ignores missing translations when missingTranslation is "ignore"', async () => { + const { outputFiles, errors, warnings } = await createInliner({ + missingTranslation: 'ignore', + }).inlineForLocale([browserFile('main.js', GREETING_SOURCE)], 'fr', { + unrelated: translationFor('Sans rapport'), + }); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toContain('"Hello"'); + }); + + it('replaces the locale placeholder with the locale being inlined', async () => { + // The placeholder is only inlined for files that use `$localize`, which is where the build + // inserts it, so the message is present alongside it here. + const { outputFiles } = await createInliner().inlineForLocale( + [ + browserFile( + 'main.js', + `export const locale = "___NG_LOCALE_INSERT___";\n${GREETING_SOURCE}`, + ), + ], + 'fr', + { greeting: translationFor('Bonjour') }, + ); + + expect(findFile(outputFiles, 'main.js').text).toContain('"fr"'); + expect(findFile(outputFiles, 'main.js').text).not.toContain('___NG_LOCALE_INSERT___'); + }); + + it('remaps the source map of a file it modifies', async () => { + // esbuild provides a map from the emitted code back to an original file, matching what the + // inliner receives during a build. + const { code, map } = await transform(GREETING_SOURCE, { + sourcefile: 'greeting.ts', + loader: 'ts', + sourcemap: 'external', + }); + + const { outputFiles } = await createInliner().inlineForLocale( + [browserFile('main.js', code), browserFile('main.js.map', map)], + 'fr', + { greeting: translationFor('Bonjour') }, + ); + + const outputMap = JSON.parse(findFile(outputFiles, 'main.js.map').text) as { + version: number; + sources: string[]; + mappings: string; + }; + + expect(outputMap.version).toBe(3); + // The map must still resolve to the original file rather than to the inliner's input. + expect(outputMap.sources).toContain('greeting.ts'); + expect(outputMap.mappings.length).toBeGreaterThan(0); + }); + + describe('inlineTemplateUpdate', () => { + it('inlines the translations of a locale into a template update', async () => { + const { code, errors, warnings } = await createInliner().inlineTemplateUpdate( + 'fr', + { greeting: translationFor('Bonjour') }, + GREETING_SOURCE, + 'template-id', + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(code).toContain('"Bonjour"'); + expect(code).not.toContain('$localize'); + }); + + it('retains the original messages for a locale without translations', async () => { + const { code, errors, warnings } = await createInliner().inlineTemplateUpdate( + 'en-US', + undefined, + GREETING_SOURCE, + 'template-id', + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(code).toContain('"Hello"'); + }); + + it('returns the code untouched when it has no localize calls', async () => { + const source = 'export const answer = 42;\n'; + const { code } = await createInliner().inlineTemplateUpdate( + 'fr', + { greeting: translationFor('Bonjour') }, + source, + 'template-id', + ); + + expect(code).toBe(source); + }); + + it('inlines translations with translationIntegrity and persistent caching', async () => { + const cacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'i18n-template-cache-test-')); + + try { + const inliner = new I18nInliner({ + missingTranslation: 'error', + persistentCachePath: cacheDir, + }); + + const translationIntegrity = 'hash-template-integrity-123'; + const { code, errors, warnings } = await inliner.inlineTemplateUpdate( + 'fr', + { greeting: translationFor('Bonjour') }, + GREETING_SOURCE, + 'template-id', + translationIntegrity, + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(code).toContain('"Bonjour"'); + await inliner.close(); + } finally { + await fs.rm(cacheDir, { recursive: true, force: true }); + } + }); + }); + + it('safely inlines translations containing special characters, quotes, and newlines', async () => { + const { outputFiles, errors, warnings } = await createInliner().inlineForLocale( + [browserFile('main.js', GREETING_SOURCE)], + 'fr', + { + greeting: translationFor('Bonjour "mon ami" \\ \' \n '), + }, + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toBe( + 'export const greeting = "Bonjour \\"mon ami\\" \\\\ \' \\n ";\n', + ); + }); + + it('inlines translations containing placeholders', async () => { + const source = 'export const welcome = (name) => $localize`:@@welcome:Hello ${name}!`;\n'; + const { outputFiles, errors, warnings } = await createInliner().inlineForLocale( + [browserFile('main.js', source)], + 'fr', + { + welcome: parsedTranslation(['Bonjour ', ' !'], ['PH'], 'Bonjour {$PH} !'), + }, + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toBe( + 'export const welcome = (name) => `Bonjour ${name} !`;\n', + ); + }); + + it('inlines multiple localize calls within the same file', async () => { + const source = + 'export const a = $localize`:@@greeting:Hello`;\nexport const b = $localize`:@@farewell:Goodbye`;\n'; + const { outputFiles, errors, warnings } = await createInliner().inlineForLocale( + [browserFile('main.js', source)], + 'fr', + { + greeting: translationFor('Bonjour'), + farewell: translationFor('Au revoir'), + }, + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toBe( + 'export const a = "Bonjour";\nexport const b = "Au revoir";\n', + ); + }); + + it('inlines translations across multiple files using multiple worker threads in parallel', async () => { + const files = [ + browserFile('main.js', GREETING_SOURCE), + browserFile('chunk1.js', GREETING_SOURCE), + browserFile('chunk2.js', GREETING_SOURCE), + browserFile('chunk3.js', GREETING_SOURCE), + ]; + inliner = new I18nInliner({ + missingTranslation: 'warning', + maxConcurrency: 4, + }); + + const { outputFiles, errors, warnings } = await inliner.inlineForLocale(files, 'fr', { + greeting: translationFor('Bonjour'), + }); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + for (const name of ['main.js', 'chunk1.js', 'chunk2.js', 'chunk3.js']) { + expect(findFile(outputFiles, name).text).toBe('export const greeting = "Bonjour";\n'); + } + }); + + it('leaves files without localize calls unmodified', async () => { + const { outputFiles } = await createInliner().inlineForLocale( + [ + browserFile('main.js', GREETING_SOURCE), + browserFile('other.js', 'export const answer = 42;\n'), + ], + 'fr', + { greeting: translationFor('Bonjour') }, + ); + + expect(findFile(outputFiles, 'other.js').text).toBe('export const answer = 42;\n'); + }); + + it('inlines nested $localize calls in post-order', async () => { + const source = + 'export const msg = $localize`:@@outer:You selected ${$localize`:@@inner:Apple`} for delivery.`;\n'; + const { outputFiles, errors, warnings } = await createInliner().inlineForLocale( + [browserFile('main.js', source)], + 'fr', + { + inner: translationFor('Pomme'), + outer: parsedTranslation( + ['Vous avez sélectionné ', ' pour la livraison.'], + ['PH'], + 'Vous avez sélectionné {$PH} pour la livraison.', + ), + }, + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toBe( + 'export const msg = `Vous avez sélectionné ${"Pomme"} pour la livraison.`;\n', + ); + }); + + it('reports an error diagnostic when a $localize template has a malformed escape sequence', async () => { + const source = 'export const msg = $localize`:@@id:\\unicode:`;\n'; + const { errors } = await createInliner().inlineForLocale( + [browserFile('main.js', source)], + 'fr', + {}, + ); + + expect(errors).toEqual([ + 'Malformed escape sequence in $localize template literal in file "main.js".', + ]); + }); + + it('inlines the translations of a locale when translationIntegrity is provided', async () => { + const { outputFiles, errors, warnings } = await createInliner().inlineForLocale( + [browserFile('main.js', GREETING_SOURCE)], + 'fr', + { greeting: translationFor('Bonjour') }, + 'sha256-test-integrity', + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toContain('"Bonjour"'); + expect(findFile(outputFiles, 'main.js').text).not.toContain('$localize'); + }); + + it('inlines the translations of a locale when localizeVersion is configured in options', async () => { + inliner = new I18nInliner({ + missingTranslation: 'warning', + localizeVersion: '20.2.0', + maxConcurrency: 1, + }); + + const { outputFiles, errors, warnings } = await inliner.inlineForLocale( + [browserFile('main.js', GREETING_SOURCE)], + 'fr', + { greeting: translationFor('Bonjour') }, + 'sha256-test-integrity', + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toContain('"Bonjour"'); + expect(findFile(outputFiles, 'main.js').text).not.toContain('$localize'); + }); + + it('inlines multiple locales in parallel via inlineAll', async () => { + const files = [ + browserFile('main.js', GREETING_SOURCE), + browserFile('chunk.js', GREETING_SOURCE), + browserFile('other.js', 'export const answer = 42;\n'), + ]; + const localeInliner = createInliner(); + + const results = await localeInliner.inlineAll(files, [ + { locale: 'fr', translation: { greeting: translationFor('Bonjour') } }, + { locale: 'de', translation: { greeting: translationFor('Hallo') } }, + { locale: 'es', translation: { greeting: translationFor('Hola') } }, + { locale: 'en-US', translation: undefined }, + ]); + + expect(results.size).toBe(4); + + const fr = results.get('fr'); + expect(fr).toBeDefined(); + expect(fr?.errors).toEqual([]); + expect(fr?.warnings).toEqual([]); + expect(findFile(fr?.outputFiles ?? [], 'main.js').text).toContain('"Bonjour"'); + expect(findFile(fr?.outputFiles ?? [], 'chunk.js').text).toContain('"Bonjour"'); + expect(findFile(fr?.outputFiles ?? [], 'other.js').text).toBe('export const answer = 42;\n'); + + const de = results.get('de'); + expect(de).toBeDefined(); + expect(de?.errors).toEqual([]); + expect(findFile(de?.outputFiles ?? [], 'main.js').text).toContain('"Hallo"'); + + const es = results.get('es'); + expect(es).toBeDefined(); + expect(es?.errors).toEqual([]); + expect(findFile(es?.outputFiles ?? [], 'main.js').text).toContain('"Hola"'); + + const en = results.get('en-US'); + expect(en).toBeDefined(); + expect(en?.errors).toEqual([]); + expect(findFile(en?.outputFiles ?? [], 'main.js').text).toContain('"Hello"'); + }); + + it('inlines multiple locales with sourcemaps in parallel via inlineAll', async () => { + const { code, map } = await transform(GREETING_SOURCE, { + sourcefile: 'greeting.ts', + loader: 'ts', + sourcemap: 'external', + }); + + const files = [ + browserFile('main.js', code), + browserFile('main.js.map', map), + browserFile('other.js', 'export const answer = 42;\n'), + ]; + const localeInliner = createInliner(); + + const results = await localeInliner.inlineAll(files, [ + { locale: 'fr', translation: { greeting: translationFor('Bonjour') } }, + { locale: 'de', translation: { greeting: translationFor('Hallo') } }, + { locale: 'en-US', translation: undefined }, + ]); + + expect(results.size).toBe(3); + + for (const [locale, greeting] of [ + ['fr', 'Bonjour'], + ['de', 'Hallo'], + ['en-US', 'Hello'], + ] as const) { + const localeResult = results.get(locale); + expect(localeResult).toBeDefined(); + expect(localeResult?.errors).toEqual([]); + expect(localeResult?.warnings).toEqual([]); + + const mainJs = findFile(localeResult?.outputFiles ?? [], 'main.js'); + expect(mainJs.text).toContain(`"${greeting}"`); + + const mainMap = findFile(localeResult?.outputFiles ?? [], 'main.js.map'); + const outputMap = JSON.parse(mainMap.text) as { + version: number; + sources: string[]; + mappings: string; + }; + expect(outputMap.version).toBe(3); + expect(outputMap.sources).toContain('greeting.ts'); + expect(outputMap.mappings.length).toBeGreaterThan(0); + + const otherJs = findFile(localeResult?.outputFiles ?? [], 'other.js'); + expect(otherJs.text).toBe('export const answer = 42;\n'); + } + }); + + it('inlines multiple locales with partial cache hits and misses via inlineAll', async () => { + const cacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'i18n-cache-test-')); + + try { + const files = [ + browserFile('main.js', GREETING_SOURCE), + browserFile('other.js', 'export const answer = 42;\n'), + ]; + const initialInliner = new I18nInliner({ + missingTranslation: 'warning', + persistentCachePath: cacheDir, + maxConcurrency: 2, + }); + + // Pre-populate cache for 'fr' + await initialInliner.inlineForLocale( + files, + 'fr', + { greeting: translationFor('Bonjour') }, + 'integrity-fr-1', + ); + await initialInliner.close(); + + // Create new inliner with same cache path, inlining cached 'fr' alongside uncached 'de' and 'es' + inliner = new I18nInliner({ + missingTranslation: 'warning', + persistentCachePath: cacheDir, + maxConcurrency: 2, + }); + + const results = await inliner.inlineAll(files, [ + { + locale: 'fr', + translation: { greeting: translationFor('Bonjour') }, + translationIntegrity: 'integrity-fr-1', + }, + { + locale: 'de', + translation: { greeting: translationFor('Hallo') }, + translationIntegrity: 'integrity-de-1', + }, + { + locale: 'es', + translation: { greeting: translationFor('Hola') }, + translationIntegrity: 'integrity-es-1', + }, + ]); + + expect(results.size).toBe(3); + + const fr = results.get('fr'); + expect(fr?.errors).toEqual([]); + expect(findFile(fr?.outputFiles ?? [], 'main.js').text).toContain('"Bonjour"'); + expect(findFile(fr?.outputFiles ?? [], 'other.js').text).toBe('export const answer = 42;\n'); + + const de = results.get('de'); + expect(de?.errors).toEqual([]); + expect(findFile(de?.outputFiles ?? [], 'main.js').text).toContain('"Hallo"'); + + const es = results.get('es'); + expect(es?.errors).toEqual([]); + expect(findFile(es?.outputFiles ?? [], 'main.js').text).toContain('"Hola"'); + + // Verify deterministic file order matching input order + for (const localeResult of results.values()) { + expect(localeResult.outputFiles.map((f) => f.path)).toEqual(['main.js', 'other.js']); + } + } finally { + await fs.rm(cacheDir, { recursive: true, force: true }); + } + }); + + it('inlines across sliding windows when locale count exceeds window size', async () => { + const locales = Array.from({ length: 20 }, (_, i) => ({ + locale: `locale-${i}`, + translation: { greeting: translationFor(`Hello ${i}`) }, + })); + + const files = [browserFile('main.js', GREETING_SOURCE)]; + inliner = new I18nInliner({ + missingTranslation: 'warning', + maxConcurrency: 2, + }); + + const results = await inliner.inlineAll(files, locales); + + expect(results.size).toBe(20); + for (let i = 0; i < 20; i++) { + const localeResult = results.get(`locale-${i}`); + expect(localeResult?.errors).toEqual([]); + expect(findFile(localeResult?.outputFiles ?? [], 'main.js').text).toContain(`"Hello ${i}"`); + } + }); + + it('injects locale data alongside ___NG_LOCALE_INSERT___ for non-English locales', async () => { + const source = `(globalThis.$localize ??= {}).locale = "___NG_LOCALE_INSERT___";\n${GREETING_SOURCE}`; + const files = [browserFile('polyfills.js', source)]; + const localeInliner = createInliner(); + + const results = await localeInliner.inlineAll(files, [ + { locale: 'fr', translation: { greeting: translationFor('Bonjour') } }, + { locale: 'en-US', translation: undefined }, + ]); + + const fr = results.get('fr'); + expect(fr?.errors).toEqual([]); + const frPolyfills = findFile(fr?.outputFiles ?? [], 'polyfills.js').text; + expect(frPolyfills).toContain('(globalThis.$localize ??= {}).locale = "fr";'); + expect(frPolyfills).toContain('ng.common.locales'); + + const en = results.get('en-US'); + expect(en?.errors).toEqual([]); + const enPolyfills = findFile(en?.outputFiles ?? [], 'polyfills.js').text; + expect(enPolyfills).toContain('(globalThis.$localize ??= {}).locale = "en-US";'); + expect(enPolyfills).not.toContain('ng.common.locales'); + }); + + it('warns and uses parent locale data when a subtag locale is not directly available', async () => { + const source = `(globalThis.$localize ??= {}).locale = "___NG_LOCALE_INSERT___";\n${GREETING_SOURCE}`; + const files = [browserFile('polyfills.js', source)]; + const localeInliner = createInliner(); + + const results = await localeInliner.inlineAll(files, [ + { locale: 'fr-ZZ', translation: { greeting: translationFor('Bonjour') } }, + ]); + + const frZZ = results.get('fr-ZZ'); + expect(frZZ?.errors).toEqual([]); + expect(frZZ?.warnings).toEqual([ + "Locale data for 'fr-ZZ' cannot be found. Using locale data for 'fr'.", + ]); + const polyfills = findFile(frZZ?.outputFiles ?? [], 'polyfills.js').text; + expect(polyfills).toContain('(globalThis.$localize ??= {}).locale = "fr-ZZ";'); + expect(polyfills).toContain('ng.common.locales'); + }); + + it('warns and includes no locale data when locale data cannot be found', async () => { + const source = `(globalThis.$localize ??= {}).locale = "___NG_LOCALE_INSERT___";\n${GREETING_SOURCE}`; + const files = [browserFile('polyfills.js', source)]; + const localeInliner = createInliner(); + + const results = await localeInliner.inlineAll(files, [ + { locale: 'xx-YY', translation: { greeting: translationFor('Test') } }, + ]); + + const xxYY = results.get('xx-YY'); + expect(xxYY?.errors).toEqual([]); + expect(xxYY?.warnings).toEqual([ + "Locale data for 'xx-YY' cannot be found. No locale data will be included for this locale.", + ]); + const polyfills = findFile(xxYY?.outputFiles ?? [], 'polyfills.js').text; + expect(polyfills).toContain('(globalThis.$localize ??= {}).locale = "xx-YY";'); + expect(polyfills).not.toContain('ng.common.locales'); + }); + + it('reports an error diagnostic when an invalid or unsupported locale is provided', async () => { + const source = `(globalThis.$localize ??= {}).locale = "___NG_LOCALE_INSERT___";\n${GREETING_SOURCE}`; + const files = [browserFile('polyfills.js', source)]; + const localeInliner = createInliner(); + + const results = await localeInliner.inlineAll(files, [ + { locale: 'invalid locale tag', translation: { greeting: translationFor('Test') } }, + ]); + + const invalid = results.get('invalid locale tag'); + expect(invalid?.errors).toEqual([ + 'Invalid or unsupported locale provided in configuration: "invalid locale tag"', + ]); + }); + + it('injects locale data script only once when multiple ___NG_LOCALE_INSERT___ sites are present', async () => { + const source = + `(globalThis.$localize ??= {}).locale = "___NG_LOCALE_INSERT___";\n` + + `const secondary = "___NG_LOCALE_INSERT___";\n${GREETING_SOURCE}`; + const files = [browserFile('polyfills.js', source)]; + const localeInliner = createInliner(); + + const results = await localeInliner.inlineAll(files, [ + { locale: 'fr', translation: { greeting: translationFor('Bonjour') } }, + ]); + + const fr = results.get('fr'); + expect(fr?.errors).toEqual([]); + const frPolyfills = findFile(fr?.outputFiles ?? [], 'polyfills.js').text; + expect(frPolyfills).toContain('(globalThis.$localize ??= {}).locale = "fr";'); + expect(frPolyfills).toContain('const secondary = "fr";'); + + const iifeMatches = frPolyfills.match(/function\s*\(global/g); + expect(iifeMatches?.length).toBe(1); + }); + + it('preserves code and sourcemaps when a file contains the $localize keyword only in comments', async () => { + const source = '// $localize keyword in comment\nexport const message = "hello world";\n'; + const { code, map } = await transform(source, { + sourcefile: 'comment-only.ts', + loader: 'ts', + sourcemap: 'external', + }); + + const files = [browserFile('main.js', code), browserFile('main.js.map', map)]; + const localeInliner = createInliner(); + + const results = await localeInliner.inlineAll(files, [ + { locale: 'fr', translation: { greeting: translationFor('Bonjour') } }, + { locale: 'de', translation: { greeting: translationFor('Hallo') } }, + ]); + + expect(results.size).toBe(2); + + for (const locale of ['fr', 'de'] as const) { + const localeResult = results.get(locale); + expect(localeResult).toBeDefined(); + expect(localeResult?.errors).toEqual([]); + expect(localeResult?.warnings).toEqual([]); + + const mainJs = findFile(localeResult?.outputFiles ?? [], 'main.js'); + expect(mainJs.text).toBe(code); + + const mainMap = findFile(localeResult?.outputFiles ?? [], 'main.js.map'); + expect(mainMap.text).toBe(map); + } + }); + + it('correctly handles persistent caching for unmodified files', async () => { + const cacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'i18n-unmodified-cache-test-')); + const source = '// $localize comment only\nexport const value = 123;\n'; + + try { + const files = [browserFile('main.js', source)]; + const inliner1 = new I18nInliner({ + missingTranslation: 'error', + persistentCachePath: cacheDir, + }); + + const results1 = await inliner1.inlineAll(files, [ + { + locale: 'fr', + translation: { greeting: translationFor('Bonjour') }, + translationIntegrity: 'fr-1', + }, + ]); + + expect(results1.get('fr')?.errors).toEqual([]); + expect(findFile(results1.get('fr')?.outputFiles ?? [], 'main.js').text).toBe(source); + await inliner1.close(); + + const inliner2 = new I18nInliner({ + missingTranslation: 'error', + persistentCachePath: cacheDir, + }); + + const results2 = await inliner2.inlineAll(files, [ + { + locale: 'fr', + translation: { greeting: translationFor('Bonjour') }, + translationIntegrity: 'fr-1', + }, + ]); + + expect(results2.get('fr')?.errors).toEqual([]); + expect(findFile(results2.get('fr')?.outputFiles ?? [], 'main.js').text).toBe(source); + await inliner2.close(); + } finally { + await fs.rm(cacheDir, { recursive: true, force: true }); + } + }); + + it('caches binary translation buffer in persistent cache store when translationIntegrity is provided', async () => { + const cacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'i18n-inliner-trans-cache-test-')); + + try { + const files = [browserFile('main.js', GREETING_SOURCE)]; + const inliner1 = new I18nInliner({ + missingTranslation: 'error', + persistentCachePath: cacheDir, + }); + + const translationIntegrity = 'hash-test-integrity-12345'; + const results1 = await inliner1.inlineAll(files, [ + { + locale: 'fr', + translation: { greeting: translationFor('Bonjour') }, + translationIntegrity, + }, + ]); + + expect(results1.get('fr')?.errors).toEqual([]); + expect(findFile(results1.get('fr')?.outputFiles ?? [], 'main.js').text).toContain( + '"Bonjour"', + ); + await inliner1.close(); + + // Verify directly that the binary translation buffer was persisted on disk + const store = await createPersistentCacheStore(path.join(cacheDir, 'angular-i18n')); + try { + const translationCache = store.createCache('translations'); + const cachedData = await translationCache.get(translationIntegrity); + expect(cachedData).toBeInstanceOf(Uint8Array); + expect(cachedData?.byteLength).toBeGreaterThan(0); + } finally { + await store.close(); + } + + const inliner2 = new I18nInliner({ + missingTranslation: 'error', + persistentCachePath: cacheDir, + }); + + const results2 = await inliner2.inlineAll(files, [ + { + locale: 'fr', + translation: { greeting: translationFor('Bonjour') }, + translationIntegrity, + }, + ]); + + expect(results2.get('fr')?.errors).toEqual([]); + expect(findFile(results2.get('fr')?.outputFiles ?? [], 'main.js').text).toContain( + '"Bonjour"', + ); + await inliner2.close(); + } finally { + await fs.rm(cacheDir, { recursive: true, force: true }); + } + }); + + it('throws an error when duplicate locales are provided to inlineAll', async () => { + const localeInliner = createInliner(); + + await expectAsync( + localeInliner.inlineAll( + [browserFile('main.js', GREETING_SOURCE)], + [ + { locale: 'fr', translation: { greeting: translationFor('Bonjour') } }, + { locale: 'fr', translation: { greeting: translationFor('Salut') } }, + ], + ), + ).toBeRejectedWithError(/Duplicate locale provided to inliner: fr/); + }); + + it('correctly transforms files across multiple inlineAll runs on the same inliner instance', async () => { + const localeInliner = new I18nInliner({ + missingTranslation: 'warning', + maxConcurrency: 2, + }); + + try { + const files1 = [browserFile('main.js', GREETING_SOURCE)]; + // First generation + const results1 = await localeInliner.inlineAll(files1, [ + { locale: 'fr', translation: { greeting: translationFor('Bonjour') } }, + ]); + expect(findFile(results1.get('fr')?.outputFiles ?? [], 'main.js').text).toContain( + '"Bonjour"', + ); + + // Second generation (e.g. watch mode rebuild with updated translation) + const files2 = [browserFile('main.js', GREETING_SOURCE)]; + const results2 = await localeInliner.inlineAll(files2, [ + { locale: 'fr', translation: { greeting: translationFor('Salut') } }, + { locale: 'de', translation: { greeting: translationFor('Hallo') } }, + ]); + expect(findFile(results2.get('fr')?.outputFiles ?? [], 'main.js').text).toContain('"Salut"'); + expect(findFile(results2.get('de')?.outputFiles ?? [], 'main.js').text).toContain('"Hallo"'); + } finally { + await localeInliner.close(); + } + }); + + it('correctly escapes backticks, double quotes, and expression delimiters in translated template literals', async () => { + const source = 'export const msg = $localize`:@@msg:Hello ${name}:name:!`;\n'; + const inliner = createInliner(); + + const results = await inliner.inlineAll( + [browserFile('main.js', source)], + [ + { + locale: 'fr', + translation: { + msg: parsedTranslation(['Bonjour "', '` with ${injected} and \\backslash!'], ['name']), + }, + }, + ], + ); + + const outputText = findFile(results.get('fr')?.outputFiles ?? [], 'main.js').text; + expect(outputText).toContain('`Bonjour "${name}\\` with \\${injected} and \\\\backslash!`'); + }); + + it('correctly inlines when files have varying sizes across multiple workers', async () => { + // Create a large dominant bundle (> 120 KB) and a small chunk (< 10 KB) + const largePadding = '/* padding */ console.log(1);\n'.repeat(4000); + const largeSource = `${GREETING_SOURCE}\n${largePadding}`; + const smallSource = 'console.log($localize`:@@farewell:Goodbye`);'; + + const largeFile = browserFile('main.js', largeSource); + const smallFile = browserFile('chunk.js', smallSource); + const files = [largeFile, smallFile]; + + inliner = new I18nInliner({ missingTranslation: 'error', maxConcurrency: 2 }); + + const results = await inliner.inlineAll(files, [ + { + locale: 'fr', + translation: { + greeting: translationFor('Bonjour'), + farewell: translationFor('Au revoir'), + }, + }, + { + locale: 'de', + translation: { + greeting: translationFor('Guten Tag'), + farewell: translationFor('Auf Wiedersehen'), + }, + }, + { + locale: 'es', + translation: { + greeting: translationFor('Hola'), + farewell: translationFor('Adios'), + }, + }, + ]); + + expect(results.size).toBe(3); + + const frFiles = results.get('fr')?.outputFiles ?? []; + expect(findFile(frFiles, 'main.js').text).toContain('"Bonjour"'); + expect(findFile(frFiles, 'chunk.js').text).toContain('"Au revoir"'); + + const deFiles = results.get('de')?.outputFiles ?? []; + expect(findFile(deFiles, 'main.js').text).toContain('"Guten Tag"'); + expect(findFile(deFiles, 'chunk.js').text).toContain('"Auf Wiedersehen"'); + + const esFiles = results.get('es')?.outputFiles ?? []; + expect(findFile(esFiles, 'main.js').text).toContain('"Hola"'); + expect(findFile(esFiles, 'chunk.js').text).toContain('"Adios"'); + }); + + it('respects maxConcurrency when processing multiple locales and files', async () => { + const files = [ + browserFile('main.js', GREETING_SOURCE), + browserFile('chunk.js', 'console.log($localize`:@@farewell:Goodbye`);'), + ]; + + inliner = new I18nInliner({ + missingTranslation: 'error', + maxConcurrency: 1, + }); + + const results = await inliner.inlineAll(files, [ + { + locale: 'fr', + translation: { + greeting: translationFor('Bonjour'), + farewell: translationFor('Au revoir'), + }, + }, + { + locale: 'de', + translation: { + greeting: translationFor('Guten Tag'), + farewell: translationFor('Auf Wiedersehen'), + }, + }, + ]); + + expect(results.size).toBe(2); + expect(findFile(results.get('fr')?.outputFiles ?? [], 'main.js').text).toContain('"Bonjour"'); + expect(findFile(results.get('de')?.outputFiles ?? [], 'chunk.js').text).toContain( + '"Auf Wiedersehen"', + ); + }); + + describe('maxConcurrency validation', () => { + it('throws when maxConcurrency is less than 1', () => { + expect( + () => + new I18nInliner({ + missingTranslation: 'warning', + maxConcurrency: 0, + }), + ).toThrowError( + RangeError, + 'options.maxConcurrency must be an integer greater than or equal to 1.', + ); + + expect( + () => + new I18nInliner({ + missingTranslation: 'warning', + maxConcurrency: -1, + }), + ).toThrowError( + RangeError, + 'options.maxConcurrency must be an integer greater than or equal to 1.', + ); + }); + + it('throws when maxConcurrency is not an integer', () => { + expect( + () => + new I18nInliner({ + missingTranslation: 'warning', + maxConcurrency: 1.5, + }), + ).toThrowError( + RangeError, + 'options.maxConcurrency must be an integer greater than or equal to 1.', + ); + + expect( + () => + new I18nInliner({ + missingTranslation: 'warning', + maxConcurrency: NaN, + }), + ).toThrowError( + RangeError, + 'options.maxConcurrency must be an integer greater than or equal to 1.', + ); + + expect( + () => + new I18nInliner({ + missingTranslation: 'warning', + maxConcurrency: Infinity, + }), + ).toThrowError( + RangeError, + 'options.maxConcurrency must be an integer greater than or equal to 1.', + ); + }); + + it('defaults to worker pool concurrency when maxConcurrency is omitted', async () => { + const maxThreadsSpy = spyOnProperty( + WorkerPool.prototype, + 'maxThreads', + 'get', + ).and.callThrough(); + + inliner = new I18nInliner({ + missingTranslation: 'warning', + }); + + const files = [browserFile('main.js', GREETING_SOURCE)]; + const results = await inliner.inlineAll(files, [ + { + locale: 'fr', + translation: { greeting: translationFor('Bonjour') }, + }, + ]); + + expect(results.size).toBe(1); + expect(findFile(results.get('fr')?.outputFiles ?? [], 'main.js').text).toContain('"Bonjour"'); + expect(maxThreadsSpy).toHaveBeenCalled(); + expect(maxThreadsSpy.calls.mostRecent().returnValue).toBeGreaterThanOrEqual(1); + }); + }); + + describe('supplied workerPool', () => { + let externalPool: WorkerPool | undefined; + + afterEach(async () => { + await externalPool?.destroy(); + externalPool = undefined; + }); + + it('uses the supplied workerPool to execute transformation tasks', async () => { + externalPool = new WorkerPool({ maxThreads: 1 }); + const runSpy = spyOn(externalPool, 'run').and.callThrough(); + + inliner = new I18nInliner({ missingTranslation: 'warning' }, externalPool); + + const files = [browserFile('main.js', GREETING_SOURCE)]; + const results = await inliner.inlineAll(files, [ + { + locale: 'fr', + translation: { greeting: translationFor('Bonjour') }, + }, + ]); + + expect(results.size).toBe(1); + expect(findFile(results.get('fr')?.outputFiles ?? [], 'main.js').text).toContain('"Bonjour"'); + expect(runSpy).toHaveBeenCalled(); + }); + + it('does not destroy the supplied workerPool on close()', async () => { + externalPool = new WorkerPool({ maxThreads: 1 }); + const destroySpy = spyOn(externalPool, 'destroy').and.callThrough(); + + inliner = new I18nInliner({ missingTranslation: 'warning' }, externalPool); + await inliner.close(); + + expect(destroySpy).not.toHaveBeenCalled(); + }); + + it('destroys internally created workerPool on close()', async () => { + const destroySpy = spyOn(WorkerPool.prototype, 'destroy').and.callThrough(); + + inliner = new I18nInliner({ missingTranslation: 'warning', maxConcurrency: 1 }); + await inliner.close(); + + expect(destroySpy).toHaveBeenCalled(); + }); + + it('supports multiple sequential inlining passes with the same supplied workerPool', async () => { + externalPool = new WorkerPool({ maxThreads: 1 }); + + inliner = new I18nInliner({ missingTranslation: 'warning' }, externalPool); + + const files1 = [browserFile('main.js', GREETING_SOURCE)]; + const results1 = await inliner.inlineAll(files1, [ + { + locale: 'fr', + translation: { greeting: translationFor('Bonjour') }, + }, + ]); + expect(findFile(results1.get('fr')?.outputFiles ?? [], 'main.js').text).toContain( + '"Bonjour"', + ); + + // Second inlining pass on the same pool + const files2 = [browserFile('main.js', GREETING_SOURCE)]; + const results2 = await inliner.inlineAll(files2, [ + { + locale: 'es', + translation: { greeting: translationFor('Hola') }, + }, + ]); + expect(findFile(results2.get('es')?.outputFiles ?? [], 'main.js').text).toContain('"Hola"'); + }); + }); +}); diff --git a/packages/angular/build/src/tools/i18n/i18n-translation-encoder.ts b/packages/angular/build/src/tools/i18n/i18n-translation-encoder.ts new file mode 100644 index 000000000000..cc8f1ae50f31 --- /dev/null +++ b/packages/angular/build/src/tools/i18n/i18n-translation-encoder.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { ɵParsedTranslation } from '@angular/localize'; + +/** + * Magic header identifier for i18n SharedArrayBuffer translation tables ('I18N'). + */ +export const I18N_MAGIC_ID = 0x4931384e; + +/** + * Encodes a JavaScript translation dictionary into a contiguous SharedArrayBuffer. + * The buffer contains a header, a sorted index table (by key for fast O(log N) binary search), + * and a UTF-8 string pool. + * + * @param translation The translation dictionary object. + * @returns A SharedArrayBuffer containing the binary encoded translation catalog. + */ +export function encodeTranslationToBuffer( + translation: Record, +): SharedArrayBuffer { + const encoder = new TextEncoder(); + const entries = Object.entries(translation); + const entryCount = entries.length; + + // Pre-encode string keys and JSON-serialized string values + const encodedEntries: { + keyBytes: Uint8Array; + valBytes: Uint8Array; + }[] = new Array(entryCount); + + let stringPoolByteSize = 0; + + for (let i = 0; i < entryCount; i++) { + const [key, val] = entries[i]; + const keyBytes = encoder.encode(key); + const valBytes = encoder.encode(JSON.stringify(val)); + + encodedEntries[i] = { keyBytes, valBytes }; + stringPoolByteSize += keyBytes.byteLength + valBytes.byteLength; + } + + // Sort entries lexicographically by UTF-8 byte comparison. + // Lexicographical byte comparison matches the binary search reader's byte comparison, + // ensuring deterministic and zero-allocation lookups across all runtimes regardless of UTF-16 surrogate pairs. + encodedEntries.sort((a, b) => { + const aBytes = a.keyBytes; + const bBytes = b.keyBytes; + const minLen = Math.min(aBytes.length, bBytes.length); + + for (let i = 0; i < minLen; i++) { + const diff = aBytes[i] - bBytes[i]; + if (diff !== 0) { + return diff; + } + } + + return aBytes.length - bBytes.length; + }); + + const indexByteSize = entryCount * 16; + const headerByteSize = 16; + const stringPoolOffset = headerByteSize + indexByteSize; + + const totalByteSize = stringPoolOffset + stringPoolByteSize; + const buffer = new SharedArrayBuffer(totalByteSize); + + // Set up views over the SharedArrayBuffer + const uint32Header = new Uint32Array(buffer, 0, 4); + const uint32Index = new Uint32Array(buffer, headerByteSize, entryCount * 4); + const uint8Pool = new Uint8Array(buffer, stringPoolOffset); + + // Write Header: Magic, EntryCount, IndexOffset, StringPoolOffset + uint32Header[0] = I18N_MAGIC_ID; + uint32Header[1] = entryCount; + uint32Header[2] = headerByteSize; + uint32Header[3] = stringPoolOffset; + + // Write Index Table and String Pool entries + let currentPoolOffset = 0; + + for (let i = 0; i < entryCount; i++) { + const { keyBytes, valBytes } = encodedEntries[i]; + + const keyOffset = currentPoolOffset; + const keyLen = keyBytes.byteLength; + uint8Pool.set(keyBytes, keyOffset); + currentPoolOffset += keyLen; + + const valOffset = currentPoolOffset; + const valLen = valBytes.byteLength; + uint8Pool.set(valBytes, valOffset); + currentPoolOffset += valLen; + + // Index entry format (4 uint32 words / 16 bytes): + // [0]: keyOffset (uint32) + // [1]: keyLength (uint32) + // [2]: valOffset (uint32) + // [3]: valLength (uint32) + const idx = i * 4; + uint32Index[idx] = keyOffset; + uint32Index[idx + 1] = keyLen; + uint32Index[idx + 2] = valOffset; + uint32Index[idx + 3] = valLen; + } + + return buffer; +} diff --git a/packages/angular/build/src/tools/i18n/i18n-translation-encoder_spec.ts b/packages/angular/build/src/tools/i18n/i18n-translation-encoder_spec.ts new file mode 100644 index 000000000000..dd660f9a4059 --- /dev/null +++ b/packages/angular/build/src/tools/i18n/i18n-translation-encoder_spec.ts @@ -0,0 +1,168 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { encodeTranslationToBuffer } from './i18n-translation-encoder'; +import { + SharedTranslationDictionary, + createSharedTranslationProxy, +} from './i18n-translation-reader'; + +describe('SharedArrayBuffer Translation Encoder & Reader', () => { + it('encodes and reads simple key-value translations', () => { + const translation = { + greeting: 'Hello', + farewell: 'Goodbye', + numberKey: 42, + arrayKey: ['part1', 'part2'], + }; + + const buffer = encodeTranslationToBuffer(translation); + const dictionary = new SharedTranslationDictionary(buffer); + + expect(dictionary.get('greeting')).toEqual('Hello'); + expect(dictionary.get('farewell')).toEqual('Goodbye'); + expect(dictionary.get('numberKey')).toEqual(42); + expect(dictionary.get('arrayKey')).toEqual(['part1', 'part2']); + expect(dictionary.get('nonexistent')).toBeUndefined(); + }); + + it('works with Proxy wrapper and "in" operator', () => { + const translation = { + msg1: 'Message 1', + msg2: 'Message 2', + }; + + const buffer = encodeTranslationToBuffer(translation); + const proxy = createSharedTranslationProxy(buffer); + + expect(proxy['msg1']).toEqual('Message 1'); + expect(proxy['msg2']).toEqual('Message 2'); + expect(proxy['unknown']).toBeUndefined(); + expect('msg1' in proxy).toBeTrue(); + expect('msg2' in proxy).toBeTrue(); + expect('unknown' in proxy).toBeFalse(); + }); + + it('supports Object.prototype.hasOwnProperty and Object.getOwnPropertyDescriptor on Proxy', () => { + const translation = { + msg1: 'Message 1', + }; + + const buffer = encodeTranslationToBuffer(translation); + const proxy = createSharedTranslationProxy(buffer); + + expect(Object.prototype.hasOwnProperty.call(proxy, 'msg1')).toBeTrue(); + expect(Object.prototype.hasOwnProperty.call(proxy, 'unknown')).toBeFalse(); + + const desc = Object.getOwnPropertyDescriptor(proxy, 'msg1'); + expect(desc).toBeDefined(); + expect(desc?.value).toEqual('Message 1'); + expect(desc?.enumerable).toBeTrue(); + + const missingDesc = Object.getOwnPropertyDescriptor(proxy, 'unknown'); + expect(missingDesc).toBeUndefined(); + + // Verify standard Object.prototype methods work and don't throw + expect(typeof proxy.toString).toBe('function'); + expect(proxy.toString()).toEqual('[object Object]'); + expect(typeof proxy.valueOf).toBe('function'); + expect('toString' in proxy).toBeTrue(); + expect(String(proxy)).toEqual('[object Object]'); + }); + + it('handles non-BMP Unicode surrogate pairs and high BMP keys correctly', () => { + const translation = { + '😀': 'emoji message', + '\uE000': 'uE000 message', + 'abc': 'abc message', + }; + + const buffer = encodeTranslationToBuffer(translation); + const dictionary = new SharedTranslationDictionary(buffer); + + expect(dictionary.get('😀')).toEqual('emoji message'); + expect(dictionary.get('\uE000')).toEqual('uE000 message'); + expect(dictionary.get('abc')).toEqual('abc message'); + }); + + it('handles empty translation dictionaries', () => { + const buffer = encodeTranslationToBuffer({}); + const dictionary = new SharedTranslationDictionary(buffer); + + expect(dictionary.get('anyKey')).toBeUndefined(); + }); + + it('caches missing translation lookups efficiently', () => { + const translation = { + msg1: 'Message 1', + }; + + const buffer = encodeTranslationToBuffer(translation); + const dictionary = new SharedTranslationDictionary(buffer); + + // First lookup for missing key + expect(dictionary.get('missingKey')).toBeUndefined(); + // Second lookup for missing key (should hit lazyCache NOT_FOUND sentinel) + expect(dictionary.get('missingKey')).toBeUndefined(); + }); + + it('validates header size and offsets on initialization', () => { + // Buffer too small + const smallSab = new SharedArrayBuffer(8); + expect(() => new SharedTranslationDictionary(smallSab)).toThrowError( + 'Invalid SharedArrayBuffer translation header: buffer too small.', + ); + + // Invalid magic header + const badMagicSab = new SharedArrayBuffer(16); + expect(() => new SharedTranslationDictionary(badMagicSab)).toThrowError( + 'Invalid SharedArrayBuffer translation header magic identifier.', + ); + + // Out-of-bounds offset values + const invalidOffsetsSab = new SharedArrayBuffer(16); + const view = new Uint32Array(invalidOffsetsSab); + view[0] = 0x4931384e; // I18N_MAGIC_ID + view[1] = 100; // entryCount = 100 entries requiring 1600 bytes + view[2] = 16; + view[3] = 16; + expect(() => new SharedTranslationDictionary(invalidOffsetsSab)).toThrowError( + 'Invalid SharedArrayBuffer translation header offsets.', + ); + + // Unaligned header size + const unalignedHeaderSab = new SharedArrayBuffer(32); + const unalignedView = new Uint32Array(unalignedHeaderSab); + unalignedView[0] = 0x4931384e; + unalignedView[1] = 0; + unalignedView[2] = 17; // unaligned header byte size + unalignedView[3] = 17; + expect(() => new SharedTranslationDictionary(unalignedHeaderSab)).toThrowError( + 'Invalid SharedArrayBuffer translation header offsets.', + ); + }); + + it('safely handles corrupted index offsets within a valid header', () => { + // 1 entry: header (16 bytes) + index (16 bytes) + pool (10 bytes) = 42 bytes + const buffer = new SharedArrayBuffer(48); + const uint32 = new Uint32Array(buffer); + uint32[0] = 0x4931384e; // Magic + uint32[1] = 1; // 1 entry + uint32[2] = 16; // header size + uint32[3] = 32; // string pool offset + + // Corrupted entry: keyOffset points past pool length + uint32[4] = 100; // keyOffset = 100 + uint32[5] = 5; // keyLen = 5 + uint32[6] = 0; // valOffset = 0 + uint32[7] = 5; // valLen = 5 + + const dictionary = new SharedTranslationDictionary(buffer); + expect(dictionary.get('testKey')).toBeUndefined(); + }); +}); diff --git a/packages/angular/build/src/tools/i18n/i18n-translation-reader.ts b/packages/angular/build/src/tools/i18n/i18n-translation-reader.ts new file mode 100644 index 000000000000..c332b47f1941 --- /dev/null +++ b/packages/angular/build/src/tools/i18n/i18n-translation-reader.ts @@ -0,0 +1,196 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { ɵParsedTranslation } from '@angular/localize'; +import { I18N_MAGIC_ID } from './i18n-translation-encoder'; + +/** + * Sentinel value used to cache non-existent message key lookups in lazyCache. + */ +const NOT_FOUND = Symbol('NOT_FOUND'); + +/** + * Compares a target key's UTF-8 byte array against a byte sequence in the string pool. + * Lexicographical byte comparison of UTF-8 sequences matches UTF-16 code-unit string comparison + * used when encoding the sorted index table. + */ +function compareBytes( + target: Uint8Array, + pool: Uint8Array, + poolOffset: number, + keyLen: number, +): number { + if (poolOffset < 0 || poolOffset + keyLen > pool.length) { + return 1; + } + + const minLen = Math.min(target.length, keyLen); + for (let i = 0; i < minLen; i++) { + const diff = target[i] - pool[poolOffset + i]; + if (diff !== 0) { + return diff; + } + } + + return target.length - keyLen; +} + +/** + * A zero-copy reader that queries translation messages directly from a SharedArrayBuffer + * using binary search over a sorted key index. + */ +export class SharedTranslationDictionary { + private readonly entryCount: number; + private readonly uint32Index: Uint32Array; + private readonly uint8Pool: Uint8Array; + private readonly decoder = new TextDecoder(); + private readonly encoder = new TextEncoder(); + private readonly lazyCache = new Map(); + + constructor(buffer: SharedArrayBuffer) { + if (buffer.byteLength < 16) { + throw new Error('Invalid SharedArrayBuffer translation header: buffer too small.'); + } + + const uint32Header = new Uint32Array(buffer, 0, 4); + if (uint32Header[0] !== I18N_MAGIC_ID) { + throw new Error('Invalid SharedArrayBuffer translation header magic identifier.'); + } + + this.entryCount = uint32Header[1]; + const headerByteSize = uint32Header[2]; + const stringPoolOffset = uint32Header[3]; + + if ( + headerByteSize < 16 || + headerByteSize % 4 !== 0 || + headerByteSize + this.entryCount * 16 > buffer.byteLength || + stringPoolOffset < headerByteSize + this.entryCount * 16 || + stringPoolOffset > buffer.byteLength + ) { + throw new Error('Invalid SharedArrayBuffer translation header offsets.'); + } + + this.uint32Index = new Uint32Array(buffer, headerByteSize, this.entryCount * 4); + this.uint8Pool = new Uint8Array(buffer, stringPoolOffset); + } + + /** + * Looks up a translation message by key. + * Performs a binary search over the index table if not previously cached in the lazy cache. + * + * @param targetKey The message key ID to search for. + * @returns The parsed translation message, or undefined if not found. + */ + get(targetKey: string): T | undefined { + const cached = this.lazyCache.get(targetKey); + if (cached !== undefined) { + return cached === NOT_FOUND ? undefined : cached; + } + + const targetBytes = this.encoder.encode(targetKey); + let low = 0; + let high = this.entryCount - 1; + + while (low <= high) { + const mid = (low + high) >>> 1; + const idx = mid * 4; + + const keyOffset = this.uint32Index[idx]; + const keyLen = this.uint32Index[idx + 1]; + + const cmp = compareBytes(targetBytes, this.uint8Pool, keyOffset, keyLen); + if (cmp === 0) { + const valOffset = this.uint32Index[idx + 2]; + const valLen = this.uint32Index[idx + 3]; + if (valOffset < 0 || valOffset + valLen > this.uint8Pool.length) { + this.lazyCache.set(targetKey, NOT_FOUND); + + return undefined; + } + + const valBytes = this.uint8Pool.subarray(valOffset, valOffset + valLen); + const valJson = this.decoder.decode(valBytes); + + try { + const val = JSON.parse(valJson) as T; + this.lazyCache.set(targetKey, val); + + return val; + } catch { + this.lazyCache.set(targetKey, NOT_FOUND); + + return undefined; + } + } else if (cmp < 0) { + high = mid - 1; + } else { + low = mid + 1; + } + } + + this.lazyCache.set(targetKey, NOT_FOUND); + + return undefined; + } +} + +/** + * Creates a JavaScript object Proxy wrapping a SharedTranslationDictionary so it can be passed + * directly to `@angular/localize` inliner functions as a standard translation Record. + * + * Traps `get`, `has`, and `getOwnPropertyDescriptor` to fulfill all `@angular/localize` `translate()` + * lookup requirements (`translations[id]`, `translations[legacyId]`, `Object.hasOwn(translations, id)`). + * The `ownKeys` trap is intentionally omitted so keys are not eagerly decoded upfront upon reflection. + * + * @param buffer The SharedArrayBuffer containing binary encoded translation catalog. + * @returns A Proxy object that intercepts property reads and queries the SharedTranslationDictionary. + */ +export function createSharedTranslationProxy( + buffer: SharedArrayBuffer, +): Record { + const dictionary = new SharedTranslationDictionary(buffer); + + return new Proxy( + {}, + { + get(target, prop: string | symbol) { + if (typeof prop === 'string') { + const value = dictionary.get(prop); + if (value !== undefined) { + return value; + } + } + + return Reflect.get(target, prop); + }, + has(target, prop: string | symbol) { + if (typeof prop === 'string' && dictionary.get(prop) !== undefined) { + return true; + } + + return Reflect.has(target, prop); + }, + getOwnPropertyDescriptor(target, prop: string | symbol) { + if (typeof prop === 'string') { + const val = dictionary.get(prop); + if (val !== undefined) { + return { + enumerable: true, + configurable: true, + writable: false, + value: val, + }; + } + } + + return Reflect.getOwnPropertyDescriptor(target, prop); + }, + }, + ); +} diff --git a/packages/angular/build/src/tools/i18n/index.ts b/packages/angular/build/src/tools/i18n/index.ts new file mode 100644 index 000000000000..a2f8cdd2d8f3 --- /dev/null +++ b/packages/angular/build/src/tools/i18n/index.ts @@ -0,0 +1,12 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export * from './i18n-inliner'; +export * from './i18n-translation-encoder'; +export * from './i18n-translation-reader'; +export * from './locale-data'; diff --git a/packages/angular/build/src/tools/i18n/locale-data.ts b/packages/angular/build/src/tools/i18n/locale-data.ts new file mode 100644 index 000000000000..0418830b69a8 --- /dev/null +++ b/packages/angular/build/src/tools/i18n/locale-data.ts @@ -0,0 +1,138 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { readFile } from 'node:fs/promises'; +import { createProjectResolver } from '../../utils/resolve-project'; + +/** + * The internal namespace used by generated locale import statements and Angular locale data plugin. + */ +export const LOCALE_DATA_NAMESPACE = 'angular:locale/data'; + +/** + * The base module location used to search for locale specific data. + */ +export const LOCALE_DATA_BASE_MODULE = '@angular/common/locales/global'; + +/** + * Result of resolving locale data for a given locale tag. + */ +export interface LocaleDataResolution { + path?: string; + warning?: string; + error?: string; +} + +/** + * Result of loading locale data for a given locale tag. + */ +export interface LoadedLocaleData { + code?: string; + warning?: string; + error?: string; +} + +const localeDataCache = new Map>(); + +/** + * Resolves the path to the Angular locale data file for a given locale tag. + * + * @param rawLocaleTag The raw locale identifier (e.g. "fr-CA", "de", "en-US"). + * @param projectResolve A function that attempts to resolve a path string to an absolute file path. + * @returns Resolution result with file path, or warning/error diagnostics if applicable. + */ +export function resolveLocaleDataPath( + rawLocaleTag: string, + projectResolve: (potentialPath: string) => string | undefined, +): LocaleDataResolution { + let partialLocaleTag: string; + try { + const locale = new Intl.Locale(rawLocaleTag); + partialLocaleTag = locale.baseName; + } catch { + return { + error: `Invalid or unsupported locale provided in configuration: "${rawLocaleTag}"`, + }; + } + + let exact = true; + while (partialLocaleTag) { + // Angular embeds the `en`/`en-US` locale into the framework and it does not need to be included again here. + if (partialLocaleTag === 'en' || partialLocaleTag === 'en-US') { + return {}; + } + + const potentialPath = `${LOCALE_DATA_BASE_MODULE}/${partialLocaleTag}`; + try { + const resolvedPath = projectResolve(potentialPath); + if (resolvedPath) { + return { + path: resolvedPath, + warning: exact + ? undefined + : `Locale data for '${rawLocaleTag}' cannot be found. Using locale data for '${partialLocaleTag}'.`, + }; + } + } catch {} + + // Remove the last subtag and try again with a less specific locale. + const parts = partialLocaleTag.split('-'); + partialLocaleTag = parts.slice(0, -1).join('-'); + exact = false; + } + + return { + warning: `Locale data for '${rawLocaleTag}' cannot be found. No locale data will be included for this locale.`, + }; +} + +/** + * Loads the Angular global locale data script for a specified locale tag. + * + * @param rawLocaleTag The raw locale identifier (e.g. "fr-CA", "de", "en-US"). + * @param projectRoot Optional project root for module resolution. + * @returns A promise resolving to the loaded locale data script code and any diagnostic warnings. + */ +export function loadLocaleData( + rawLocaleTag: string, + projectRoot?: string, +): Promise { + let cached = localeDataCache.get(rawLocaleTag); + if (!cached) { + cached = (async () => { + const projectResolve = createProjectResolver(projectRoot ?? process.cwd()); + const resolution = resolveLocaleDataPath(rawLocaleTag, (potentialPath) => { + try { + return projectResolve(potentialPath); + } catch { + return undefined; + } + }); + + if (resolution.error) { + return { error: resolution.error }; + } + + if (resolution.path) { + try { + const code = await readFile(resolution.path, 'utf8'); + + return { code, warning: resolution.warning }; + } catch (e) { + return { error: `Failed to read locale data file: ${(e as Error).message}` }; + } + } + + return { warning: resolution.warning }; + })(); + + localeDataCache.set(rawLocaleTag, cached); + } + + return cached; +} diff --git a/packages/angular/build/src/tools/javascript-transformer/index.ts b/packages/angular/build/src/tools/javascript-transformer/index.ts new file mode 100644 index 000000000000..399eea87e644 --- /dev/null +++ b/packages/angular/build/src/tools/javascript-transformer/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export * from './javascript-transformer'; diff --git a/packages/angular/build/src/tools/javascript-transformer/javascript-transformer-worker.ts b/packages/angular/build/src/tools/javascript-transformer/javascript-transformer-worker.ts new file mode 100644 index 000000000000..87df19ee81eb --- /dev/null +++ b/packages/angular/build/src/tools/javascript-transformer/javascript-transformer-worker.ts @@ -0,0 +1,270 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { DecodedSourceMap, EncodedSourceMap } from '@ampproject/remapping'; +import type { PluginItem } from '@babel/core'; +import { createRequire } from 'node:module'; +import { workerData } from 'node:worker_threads'; +import Piscina from 'piscina'; +import { useBabelLinker } from '../../utils/environment-options.js'; +import { + findTrailingSourceMapComment, + isTrailingSourceMapComment, + loadInputSourceMap, + loadInputSourceMapFromUrl, + removeSourceMappingURL, +} from '../../utils/source-map'; +import { transform as transformWithOxc } from '../oxc/oxc-transform.js'; +import type { JavaScriptTransformerOptions } from './javascript-transformer'; + +interface JavaScriptTransformRequest { + filename: string; + data: string | Uint8Array; + skipLinker?: boolean; + sideEffects?: boolean; + instrumentForCoverage?: boolean; +} + +interface TransformOptions extends Omit { + inputSourceMap?: EncodedSourceMap; + isAlreadyStripped?: boolean; +} + +const { + sourcemap = false, + thirdPartySourcemaps = false, + advancedOptimizations = false, + jit = false, +} = (workerData || {}) as Partial; + +let babelLinkerDeps: + | Promise< + [ + typeof import('@angular/compiler-cli/linker/babel'), + typeof import('@angular/compiler-cli'), + typeof import('@babel/core'), + ] + > + | undefined; + +let remapping: typeof import('@ampproject/remapping').default | undefined; + +const textDecoder = new TextDecoder(); +const textEncoder = new TextEncoder(); + +async function instrumentCoverage( + filename: string, + data: string, + useInputSourcemap: boolean, +): Promise<{ code: string; map?: EncodedSourceMap }> { + try { + let resolvedPath = 'istanbul-lib-instrument'; + try { + const requireFn = createRequire(filename); + resolvedPath = requireFn.resolve('istanbul-lib-instrument'); + } catch { + // Fallback to pool worker import traversal + } + + const { createInstrumenter } = (await import( + resolvedPath + )) as typeof import('istanbul-lib-instrument'); + const instrumenter = createInstrumenter({ + produceSourceMap: useInputSourcemap, + esModules: true, + }); + + const inputSourceMap = useInputSourcemap ? loadInputSourceMap(filename, data) : undefined; + const instrumentedCode = instrumenter.instrumentSync( + data, + filename, + inputSourceMap as Parameters[2], + ); + const lastMap = useInputSourcemap + ? (instrumenter.lastSourceMap() as EncodedSourceMap) + : undefined; + + return { + code: instrumentedCode, + map: lastMap ?? undefined, + }; + } catch (error) { + throw new Error( + `The 'istanbul-lib-instrument' package is required for code coverage but was not found. Please install the package.`, + { cause: error }, + ); + } +} + +export default async function transformJavaScript( + request: JavaScriptTransformRequest, +): Promise { + const { filename, data, ...options } = request; + + const useInputSourcemap = + sourcemap && (!!thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename)); + + let textData: string; + let inputSourceMap: EncodedSourceMap | undefined; + let isAlreadyStripped = false; + + if (typeof data !== 'string') { + const trailing = findTrailingSourceMapComment(data); + if (trailing === null) { + // 0 comments: fast path, no sourcemap to load or strip + textData = textDecoder.decode(data); + isAlreadyStripped = true; + } else if (trailing !== undefined) { + if (useInputSourcemap) { + inputSourceMap = loadInputSourceMapFromUrl(filename, trailing.urlLine); + if (inputSourceMap !== undefined) { + // Valid trailing sourcemap comment confirmed: safe to slice code buffer for transformation passes. + // Note: If no passes modify the code, the untouched original `data` buffer is returned below. + textData = textDecoder.decode(trailing.code); + isAlreadyStripped = true; + } else { + // Not a valid trailing sourcemap (e.g. inside template literal): fallback to full decode + textData = textDecoder.decode(data); + } + } else if (isTrailingSourceMapComment(trailing.urlLine)) { + // Valid trailing sourcemap comment confirmed: safe to slice code buffer + textData = textDecoder.decode(trailing.code); + isAlreadyStripped = true; + } else { + // Fallback to full decode and state-machine stripping + textData = textDecoder.decode(data); + } + } else { + // Multiple comments or comment not at line start: fall back to full decode and string parser + textData = textDecoder.decode(data); + } + } else { + textData = data; + } + + const transformedData = await transformJavaScriptImpl(filename, textData, { + ...options, + inputSourceMap, + isAlreadyStripped, + }); + + // If no transformations modified the code, return the original untouched data buffer via `move`. + // This preserves any original trailing sourcemap comment and avoids re-encoding. + if (transformedData === textData && typeof data !== 'string') { + return Piscina.move(data); + } + + return Piscina.move(textEncoder.encode(transformedData)); +} + +async function transformJavaScriptImpl( + filename: string, + data: string, + options: TransformOptions, +): Promise { + const shouldLink = !options.skipLinker; + const useInputSourcemap = + sourcemap && (!!thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename)); + + let code = data; + const maps: (DecodedSourceMap | EncodedSourceMap)[] = []; + let coverageMap: EncodedSourceMap | undefined; + + if (options.instrumentForCoverage) { + const result = await instrumentCoverage(filename, code, useInputSourcemap); + code = result.code; + coverageMap = result.map; + } + + if (shouldLink && useBabelLinker) { + babelLinkerDeps ??= Promise.all([ + import('@angular/compiler-cli/linker/babel'), + import('@angular/compiler-cli'), + import('@babel/core'), + ]); + const [{ createEs2015LinkerPlugin }, { ConsoleLogger, LogLevel }, { transformAsync }] = + await babelLinkerDeps; + + const result = await transformAsync(code, { + filename, + inputSourceMap: false, + sourceMaps: !!useInputSourcemap, + compact: false, + configFile: false, + babelrc: false, + browserslistConfigFile: false, + plugins: [ + createEs2015LinkerPlugin({ + fileSystem: { + exists: () => false, + readFile: () => '', + resolve: (...paths: string[]) => paths.join('/'), + dirname: (path: string) => path.split('/').slice(0, -1).join('/'), + relative: (_from: string, to: string) => to, + } as never, + logger: new ConsoleLogger(LogLevel.info), + linkerJitMode: jit, + // This is a workaround until https://github.com/angular/angular/issues/42769 is fixed. + sourceMapping: false, + }) as PluginItem, + ], + }); + + code = result?.code ?? code; + if (result?.map) { + maps.push(result.map as EncodedSourceMap); + } + } + + // Run Oxc linking and/or advanced optimizations in a single unified AST traversal pass + const oxcLink = shouldLink && !useBabelLinker; + if (oxcLink || advancedOptimizations) { + const sideEffectFree = options.sideEffects === false; + const safeAngularPackage = + sideEffectFree && /[\\/]node_modules[\\/]@angular[\\/]/.test(filename); + const topLevelSafeMode = !safeAngularPackage; + + const result = transformWithOxc(filename, code, { + link: oxcLink, + jit, + advancedOptimizations, + sourcemap: useInputSourcemap, + sideEffects: options.sideEffects, + topLevelSafeMode, + }); + code = result.code; + if (result.map) { + maps.push(result.map); + } + } + + if (useInputSourcemap) { + const baseMap = coverageMap ?? options.inputSourceMap ?? loadInputSourceMap(filename, data); + if (maps.length > 0 || coverageMap) { + if (!options.isAlreadyStripped) { + code = removeSourceMappingURL(code); + } + const remappingChain: (DecodedSourceMap | EncodedSourceMap)[] = maps.reverse(); + if (baseMap) { + remappingChain.push(baseMap); + } + + if (remappingChain.length > 0) { + remapping ??= (await import('@ampproject/remapping')).default; + const finalMap = remapping(remappingChain, () => null).toString(); + const base64Map = Buffer.from(finalMap).toString('base64'); + code += `\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,${base64Map}`; + } + } + + return code; + } + + // Strip sourcemaps if they should not be used + return options.isAlreadyStripped ? code : removeSourceMappingURL(code); +} diff --git a/packages/angular/build/src/tools/javascript-transformer/javascript-transformer.ts b/packages/angular/build/src/tools/javascript-transformer/javascript-transformer.ts new file mode 100644 index 000000000000..56a7b4eb6ed9 --- /dev/null +++ b/packages/angular/build/src/tools/javascript-transformer/javascript-transformer.ts @@ -0,0 +1,393 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { readFile } from 'node:fs/promises'; +import { Cache } from '../../utils/cache'; +import { createContentHash } from '../../utils/hash'; +import { IMPORT_EXEC_ARGV } from '../../utils/server-rendering/esm-in-memory-loader/utils'; +import { removeSourceMappingURL } from '../../utils/source-map'; +import { WorkerPool, WorkerPoolOptions } from '../../utils/worker-pool'; + +const LINKER_DECLARATION_PREFIX = 'ɵɵngDeclare'; +const LINKER_DECLARATION_PREFIX_BYTES = Buffer.from(LINKER_DECLARATION_PREFIX, 'utf-8'); + +const ADVANCED_OPTIMIZATION_TOKENS = [ + 'ɵ', + 'InjectionToken', + 'INJECTOR_KEY', + 'ctorParameters', + 'decorators', + 'propDecorators', +] as const; + +const ADVANCED_OPTIMIZATION_TOKEN_BYTES = ADVANCED_OPTIMIZATION_TOKENS.map((token) => + Buffer.from(token, 'utf-8'), +); + +const DECORATOR_TOKENS = ['__decorate', '__esDecorate'] as const; +const DECORATOR_TOKEN_BYTES = DECORATOR_TOKENS.map((token) => Buffer.from(token, 'utf-8')); + +const ADVANCED_OPTIMIZATION_REGEX = new RegExp(ADVANCED_OPTIMIZATION_TOKENS.join('|')); +const DECORATOR_REGEX = new RegExp(DECORATOR_TOKENS.join('|')); + +/** + * Determines whether JavaScript code contains potential candidate constructs for advanced optimizations. + * When false, advanced optimizations can be bypassed without worker dispatch or AST parsing. + * + * @param filename The full path to the file. + * @param data The data (string or Buffer) of the file. + * @param sideEffects An optional lazy resolver callback that returns whether the file is considered side-effect free. + * @returns True if the code may contain constructs that advanced optimizations can mutate. + */ +async function hasAdvancedOptimizationCandidates( + filename: string, + data: string | Uint8Array, + sideEffects?: () => Promise, +): Promise { + // Side-effect-free @angular/ packages undergo top-level pure function annotations + if (/[\\/]node_modules[\\/]@angular[\\/]/.test(filename) && (await sideEffects?.()) === false) { + return true; + } + + if (typeof data === 'string') { + const hasDecorators = DECORATOR_REGEX.test(data); + if (hasDecorators && (await sideEffects?.()) === false) { + return true; + } + + return ADVANCED_OPTIMIZATION_REGEX.test(data); + } + + const dataBuffer = Buffer.isBuffer(data) + ? data + : Buffer.from(data.buffer, data.byteOffset, data.byteLength); + + for (const tokenBytes of DECORATOR_TOKEN_BYTES) { + if (dataBuffer.includes(tokenBytes)) { + if ((await sideEffects?.()) === false) { + return true; + } + break; + } + } + + for (const tokenBytes of ADVANCED_OPTIMIZATION_TOKEN_BYTES) { + if (dataBuffer.includes(tokenBytes)) { + return true; + } + } + + return false; +} + +/** + * Determines whether JavaScript code requires Angular linker processing. + * + * @param path The full path to the file. + * @param data The data (string or Buffer) of the file. + * @returns True if the code contains an Angular partial declaration; otherwise false. + */ +function requiresLinking(path: string, data: string | Uint8Array): boolean { + // @angular/core and @angular/compiler will cause false positives + // Also, TypeScript files do not require linking + if (/[\\/]@angular[\\/](?:compiler|core)[\\/]|\.[cm]?tsx?$/.test(path)) { + return false; + } + + if (typeof data === 'string') { + return data.includes(LINKER_DECLARATION_PREFIX); + } + + const dataBuffer = Buffer.isBuffer(data) + ? data + : Buffer.from(data.buffer, data.byteOffset, data.byteLength); + + return dataBuffer.includes(LINKER_DECLARATION_PREFIX_BYTES); +} + +/** + * Transformation options that should apply to all transformed files and data. + */ +export interface JavaScriptTransformerOptions { + sourcemap: boolean; + thirdPartySourcemaps?: boolean; + advancedOptimizations?: boolean; + jit?: boolean; + + /** + * The maximum number of concurrent transformation operations. + * When omitted, concurrency defaults to the available worker pool threads. + */ + maxConcurrency?: number; +} + +/** + * Transformation options for an individual file or data transform request. + */ +export interface TransformOptions { + /** If true, bypass all Angular linker processing; if false, attempt linking. */ + skipLinker?: boolean; + + /** + * An optional lazy resolver callback that returns whether the file has side-effects. + * If it resolves to false, top-level pure function annotations and decorator wrapping may be applied. + */ + sideEffects?: () => Promise; + + /** If true, instrument the code for test coverage. */ + instrumentForCoverage?: boolean; +} + +/** + * A class that performs transformation of JavaScript files and raw data. + * A worker pool is used to distribute the transformation actions and allow + * parallel processing. Transformation behavior is based on the filename and + * data. Transformations may include: async downleveling, Angular linking, + * and advanced optimizations. + */ +export class JavaScriptTransformer { + #workerPool: WorkerPool | undefined; + #commonOptions: Required>; + #fileCacheKeyBase: Uint8Array; + + /** Queue of pending transformation tasks waiting for an active concurrency slot. */ + #pendingTasks: { resolve: () => void; reject: (reason: Error) => void }[] = []; + + /** Current count of actively executing transformation tasks. */ + #activeTasks = 0; + + get #maxConcurrency(): number { + return this.options.maxConcurrency ?? (this.#workerPool?.maxThreads || 1); + } + + constructor( + private readonly options: JavaScriptTransformerOptions, + private readonly cache?: Cache, + ) { + if ( + options.maxConcurrency !== undefined && + (!Number.isInteger(options.maxConcurrency) || options.maxConcurrency < 1) + ) { + throw new RangeError('options.maxConcurrency must be an integer greater than or equal to 1.'); + } + + // Extract options to ensure only the named options are serialized and sent to the worker + const { + sourcemap, + thirdPartySourcemaps = false, + advancedOptimizations = false, + jit = false, + } = options; + this.#commonOptions = { + sourcemap, + thirdPartySourcemaps, + advancedOptimizations, + jit, + }; + this.#fileCacheKeyBase = Buffer.from(JSON.stringify(this.#commonOptions), 'utf-8'); + this.#workerPool = this.#ensureWorkerPool(); + } + + /** + * Executes a transformation action using a semaphore-based backpressure throttle. + * Prevents libuv thread pool saturation and excessive V8 heap accumulation. + * @param action A callback that produces a promise for the transformation result. + * @returns A promise resolving to the transformation result. + */ + async #runWithThrottle(action: () => Promise): Promise { + if (this.#activeTasks >= this.#maxConcurrency) { + await new Promise((resolve, reject) => { + this.#pendingTasks.push({ resolve, reject }); + }); + } else { + this.#activeTasks++; + } + + try { + return await action(); + } finally { + const next = this.#pendingTasks.shift(); + if (next) { + next.resolve(); + } else { + this.#activeTasks--; + } + } + } + + #ensureWorkerPool(): WorkerPool { + if (this.#workerPool) { + return this.#workerPool; + } + + const workerPoolOptions: WorkerPoolOptions = { + filename: require.resolve('./javascript-transformer-worker'), + workerData: this.#commonOptions, + ...(this.options.maxConcurrency !== undefined && { + minThreads: this.options.maxConcurrency, + maxThreads: this.options.maxConcurrency, + }), + }; + + // Prevent passing SSR `--import` (loader-hooks) from parent to child worker. + const filteredExecArgv = process.execArgv.filter((v) => v !== IMPORT_EXEC_ARGV); + if (process.execArgv.length !== filteredExecArgv.length) { + workerPoolOptions.execArgv = filteredExecArgv; + } + + this.#workerPool = new WorkerPool(workerPoolOptions); + + return this.#workerPool; + } + + /** + * Performs JavaScript transformations on a file from the filesystem. + * If no transformations are required, the data for the original file will be returned. + * @param filename The full path to the file. + * @param options Transformation options specific to this file. + * @returns A promise that resolves to a UTF-8 encoded Uint8Array containing the result. + */ + async transformFile(filename: string, options?: TransformOptions): Promise { + return this.#runWithThrottle(async () => { + const data = await readFile(filename); + + return this.#transform(filename, data, options); + }); + } + + /** + * Performs JavaScript transformations on the provided data of a file. The file does not need + * to exist on the filesystem. + * @param filename The full path of the file represented by the data. + * @param data The data of the file that should be transformed. + * @param options Transformation options specific to this file data. + * @returns A promise that resolves to a UTF-8 encoded Uint8Array containing the result. + */ + async transformData( + filename: string, + data: string | Uint8Array, + options?: TransformOptions, + ): Promise { + return this.#runWithThrottle(() => this.#transform(filename, data, options)); + } + + async #transform( + filename: string, + data: string | Uint8Array, + options?: TransformOptions, + ): Promise { + let resolvedSideEffects: boolean | undefined; + let sideEffectsQueried = false; + + const sideEffectsGetter = options?.sideEffects + ? async () => { + if (!sideEffectsQueried) { + sideEffectsQueried = true; + resolvedSideEffects = await options.sideEffects?.(); + } + + return resolvedSideEffects; + } + : undefined; + + const shouldLink = !options?.skipLinker && requiresLinking(filename, data); + const shouldOptimize = + this.#commonOptions.advancedOptimizations && + (await hasAdvancedOptimizationCandidates(filename, data, sideEffectsGetter)); + + // Perform a quick test to determine if the data needs any transformations. + // This allows directly returning the data without the worker communication overhead. + if (!shouldLink && !shouldOptimize && !options?.instrumentForCoverage) { + const keepSourcemap = + this.#commonOptions.sourcemap && + (!!this.#commonOptions.thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename)); + + if (typeof data === 'string') { + return Buffer.from(keepSourcemap ? data : removeSourceMappingURL(data), 'utf-8'); + } + + return keepSourcemap ? data : removeSourceMappingURL(data); + } + + let cacheKey: string | undefined; + if (this.cache) { + // Create a cache key from the file data and options that affect the output. + // NOTE: If additional options are added, this may need to be updated. + const hasher = createContentHash(); + hasher.update( + `${!options?.skipLinker}--${resolvedSideEffects === false}--${!!options?.instrumentForCoverage}`, + ); + hasher.update(data); + hasher.update(this.#fileCacheKeyBase); + cacheKey = hasher.digest(); + + try { + const cached = await this.cache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + } catch { + // Failure to get the value should not fail the transform + } + } + + // Only standalone (non-pooled) ArrayBuffers can be transferred across worker threads. + // Node.js shares an internal 8KB ArrayBuffer pool for small buffers, and transferring + // a pooled buffer will throw a DataCloneError because detaching it invalidates other slices. + // In addition, SharedArrayBuffers cannot be transferred, and Yarn PnP has deserialization issues. + const isTransferable = + typeof data !== 'string' && + data.buffer instanceof ArrayBuffer && + data.byteOffset === 0 && + data.byteLength === data.buffer.byteLength && + !process.versions.pnp; + + const result = (await this.#ensureWorkerPool().run( + { + filename, + data, + skipLinker: !shouldLink, + sideEffects: resolvedSideEffects, + instrumentForCoverage: options?.instrumentForCoverage, + }, + { + transferList: isTransferable ? [data.buffer] : undefined, + }, + )) as Uint8Array; + + if (this.cache && cacheKey) { + try { + await this.cache.put(cacheKey, result); + } catch { + // Failure to store the value in the cache should not fail the transform + } + } + + return result; + } + + /** + * Stops all active transformation tasks and shuts down all workers. + * @returns A void promise that resolves when closing is complete. + */ + async close(): Promise { + const pending = this.#pendingTasks; + this.#pendingTasks = []; + for (const task of pending) { + task.reject(new Error('JavaScriptTransformer closed.')); + } + + if (this.#workerPool) { + try { + await this.#workerPool.destroy(); + } finally { + this.#workerPool = undefined; + } + } + } +} diff --git a/packages/angular/build/src/tools/javascript-transformer/javascript-transformer_spec.ts b/packages/angular/build/src/tools/javascript-transformer/javascript-transformer_spec.ts new file mode 100644 index 000000000000..94d671ef2a72 --- /dev/null +++ b/packages/angular/build/src/tools/javascript-transformer/javascript-transformer_spec.ts @@ -0,0 +1,662 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { JavaScriptTransformer } from './javascript-transformer'; + +describe('JavaScriptTransformer sourcemaps', () => { + let transformer: JavaScriptTransformer; + + afterEach(async () => { + await transformer?.close(); + }); + + function extractSourcemap(code: string): Record | null { + const match = code.match( + /\/\/# sourceMappingURL=data:application\/json;charset=utf-8;base64,(.+)/, + ); + if (!match) { + return null; + } + + return JSON.parse(Buffer.from(match[1], 'base64').toString('utf-8')); + } + + it('should remap correctly when only advanced optimizations are applied', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: true, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const inputMap = { + version: 3, + sources: ['src/app.ts'], + sourcesContent: ['export class MyClass { static ɵprov = 42; }'], + mappings: 'AAAA', + names: [], + }; + const base64Map = Buffer.from(JSON.stringify(inputMap)).toString('base64'); + const input = `export class MyClass { static ɵprov = 42; }\n//# sourceMappingURL=data:application/json;base64,${base64Map}`; + + const result = await transformer.transformData('src/app.js', input, { skipLinker: true }); + const text = Buffer.from(result).toString('utf-8'); + const map = extractSourcemap(text); + + expect(map).toBeDefined(); + expect(map?.['version']).toBe(3); + expect(map?.['sources']).toContain('src/app.ts'); + expect(typeof map?.['mappings']).toBe('string'); + expect((map?.['mappings'] as string).length).toBeGreaterThan(0); + }); + + it('should remap correctly when only linking is applied', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: true, + thirdPartySourcemaps: true, + maxConcurrency: 1, + }); + + const inputMap = { + version: 3, + sources: ['node_modules/my-lib/directive.ts'], + sourcesContent: ['export class MyDirective {}'], + mappings: 'AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA', + names: [], + }; + const base64Map = Buffer.from(JSON.stringify(inputMap)).toString('base64'); + const input = ` + import * as i0 from "@angular/core"; + export class MyDirective {} + MyDirective.ɵdir = i0.ɵɵngDeclareDirective({ + minVersion: "12.0.0", + version: "14.0.0", + ngImport: i0, + type: MyDirective, + selector: "[my-dir]" + }); + //# sourceMappingURL=data:application/json;base64,${base64Map} + `; + + const result = await transformer.transformData('node_modules/my-lib/directive.js', input, { + skipLinker: false, + }); + const text = Buffer.from(result).toString('utf-8'); + const map = extractSourcemap(text); + + expect(map).toBeDefined(); + expect(map?.['version']).toBe(3); + expect(map?.['sources']).toContain('node_modules/my-lib/directive.ts'); + expect(typeof map?.['mappings']).toBe('string'); + expect((map?.['mappings'] as string).length).toBeGreaterThan(0); + }); + + it('should defer and chain remapping when both linking and advanced optimizations are applied', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: true, + thirdPartySourcemaps: true, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const inputMap = { + version: 3, + sources: ['node_modules/my-lib/component.ts'], + sourcesContent: ['export class MyComponent {}'], + mappings: 'AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA', + names: [], + }; + const base64Map = Buffer.from(JSON.stringify(inputMap)).toString('base64'); + const input = ` + import * as i0 from "@angular/core"; + export class MyComponent {} + MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ + minVersion: "12.0.0", + version: "14.0.0", + ngImport: i0, + type: MyComponent, + selector: "my-cmp", + template: "
" + }); + //# sourceMappingURL=data:application/json;base64,${base64Map} + `; + + const result = await transformer.transformData('node_modules/my-lib/component.js', input, { + skipLinker: false, + }); + const text = Buffer.from(result).toString('utf-8'); + const map = extractSourcemap(text); + + expect(map).toBeDefined(); + expect(map?.['version']).toBe(3); + expect(map?.['sources']).toContain('node_modules/my-lib/component.ts'); + expect(typeof map?.['mappings']).toBe('string'); + expect((map?.['mappings'] as string).length).toBeGreaterThan(0); + }); + + it('should produce a valid sourcemap when no input sourcemap is present', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: true, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const input = 'export class MyClass { static ɵprov = 42; }'; + const result = await transformer.transformData('src/app.js', input, { skipLinker: true }); + const text = Buffer.from(result).toString('utf-8'); + const map = extractSourcemap(text); + + expect(map).toBeDefined(); + expect(map?.['version']).toBe(3); + expect(map?.['sources']).toContain('src/app.js'); + expect(typeof map?.['mappings']).toBe('string'); + expect((map?.['mappings'] as string).length).toBeGreaterThan(0); + }); + + it('should remap correctly when coverage instrumentation is applied with an input sourcemap', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: true, + maxConcurrency: 1, + }); + + const inputMap = { + version: 3, + sources: ['src/counter.ts'], + sourcesContent: ['export function add(a: number, b: number) { return a + b; }'], + mappings: 'AAAA', + names: [], + }; + const base64Map = Buffer.from(JSON.stringify(inputMap)).toString('base64'); + const input = `export function add(a, b) { return a + b; }\n//# sourceMappingURL=data:application/json;base64,${base64Map}`; + + const result = await transformer.transformData('src/counter.js', input, { + skipLinker: true, + instrumentForCoverage: true, + }); + const text = Buffer.from(result).toString('utf-8'); + const map = extractSourcemap(text); + + expect(map).toBeDefined(); + expect(map?.['version']).toBe(3); + expect(map?.['sources']).toContain('src/counter.ts'); + expect(typeof map?.['mappings']).toBe('string'); + expect((map?.['mappings'] as string).length).toBeGreaterThan(0); + }); + + it('should defer and chain remapping when coverage instrumentation and advanced optimizations are applied', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: true, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const inputMap = { + version: 3, + sources: ['src/app.ts'], + sourcesContent: ['const x = new SomeClass();'], + mappings: 'AAAA', + names: [], + }; + const base64Map = Buffer.from(JSON.stringify(inputMap)).toString('base64'); + const input = `var x = new SomeClass();\n//# sourceMappingURL=data:application/json;base64,${base64Map}`; + + const result = await transformer.transformData('src/app.js', input, { + skipLinker: true, + instrumentForCoverage: true, + }); + const text = Buffer.from(result).toString('utf-8'); + const map = extractSourcemap(text); + + expect(map).toBeDefined(); + expect(map?.['version']).toBe(3); + expect(map?.['sources']).toContain('src/app.ts'); + expect(typeof map?.['mappings']).toBe('string'); + expect((map?.['mappings'] as string).length).toBeGreaterThan(0); + }); + + it('should accept a Uint8Array input in transformData', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: true, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const inputBuffer = Buffer.from('export class MyClass { static ɵprov = 42; }', 'utf-8'); + const result = await transformer.transformData('src/app.js', inputBuffer, { skipLinker: true }); + const text = Buffer.from(result).toString('utf-8'); + const map = extractSourcemap(text); + + expect(map).toBeDefined(); + expect(map?.['version']).toBe(3); + expect(map?.['sources']).toContain('src/app.js'); + expect(typeof map?.['mappings']).toBe('string'); + }); + + it('should strip trailing sourcemap comments from Uint8Array input on fast-path', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + maxConcurrency: 1, + }); + + const inputBuffer = Buffer.from( + 'console.log("hello");\n//# sourceMappingURL=app.js.map', + 'utf-8', + ); + const result = await transformer.transformData('node_modules/my-lib/lib.js', inputBuffer, { + skipLinker: true, + }); + const text = Buffer.from(result).toString('utf-8'); + + expect(text).toBe('console.log("hello");\n'); + }); + + it('should return Uint8Array input untouched on fast-path when no sourcemap comment is present', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + maxConcurrency: 1, + }); + + const inputBuffer = Buffer.from('console.log("hello");\nconst x = 1;', 'utf-8'); + const result = await transformer.transformData('node_modules/my-lib/lib.js', inputBuffer, { + skipLinker: true, + }); + + expect(result).toBe(inputBuffer); + }); + + it('should return Uint8Array untouched when skipLinker is false but file contains no linker declarations', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + maxConcurrency: 1, + }); + + const inputBuffer = Buffer.from('console.log("no linking required");\nconst x = 1;', 'utf-8'); + const result = await transformer.transformData('node_modules/my-lib/lib.js', inputBuffer, { + skipLinker: false, + }); + + expect(result).toBe(inputBuffer); + }); + + it('should bypass worker and skip linking for @angular/core and @angular/compiler paths', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + maxConcurrency: 1, + }); + + const inputBuffer = Buffer.from('export const ɵɵngDeclareDirective = () => {};', 'utf-8'); + const result = await transformer.transformData( + 'node_modules/@angular/core/fesm2022/core.mjs', + inputBuffer, + { skipLinker: false }, + ); + + expect(result).toBe(inputBuffer); + }); + + it('should bypass worker and skip linking for TypeScript file extensions (.ts, .tsx, .mts, .cts)', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + maxConcurrency: 1, + }); + + const inputBuffer = Buffer.from('export const ɵɵngDeclareDirective = () => {};', 'utf-8'); + + for (const ext of ['.ts', '.tsx', '.mts', '.cts']) { + const result = await transformer.transformData(`src/app/directive${ext}`, inputBuffer, { + skipLinker: false, + }); + + expect(result).toBe(inputBuffer); + } + }); + + it('should not exclude packages with similar prefixes such as @angular/compiler-cli', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + maxConcurrency: 1, + }); + + const input = ` + import * as i0 from "@angular/core"; + export class MyDirective {} + MyDirective.ɵdir = i0.ɵɵngDeclareDirective({ + minVersion: "12.0.0", + version: "14.0.0", + ngImport: i0, + type: MyDirective, + selector: "[my-dir]" + }); + `; + + const result = await transformer.transformData( + 'node_modules/@angular/compiler-cli/test.js', + input, + { skipLinker: false }, + ); + const text = Buffer.from(result).toString('utf-8'); + + expect(text).not.toContain('i0.ɵɵngDeclareDirective'); + }); + + describe('advanced optimizations fast-path pre-filter', () => { + it('should bypass worker and return input buffer directly when no candidate tokens are present', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const inputBuffer = Buffer.from( + 'function add(a, b) { return a + b; }\nconst result = add(1, 2);', + 'utf-8', + ); + const result = await transformer.transformData('src/math.js', inputBuffer, { + skipLinker: true, + }); + + expect(result).toBe(inputBuffer); + }); + + it('should bypass worker for standard classes without static properties', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const inputBuffer = Buffer.from( + `export class UserService { + constructor(http) { this.http = http; } + getUser(id) { return this.http.get('/users/' + id); } + }`, + 'utf-8', + ); + const result = await transformer.transformData('src/user.service.js', inputBuffer, { + skipLinker: true, + }); + + expect(result).toBe(inputBuffer); + }); + + it('should bypass worker for default exports without static properties or Angular metadata', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const inputBuffer = Buffer.from( + `export default class UserService { + constructor(http) { this.http = http; } + getUser(id) { return this.http.get('/users/' + id); } + }`, + 'utf-8', + ); + const result = await transformer.transformData('src/user.service.js', inputBuffer, { + skipLinker: true, + }); + + expect(result).toBe(inputBuffer); + }); + + it('should bypass worker for classes with static members when no Angular metadata is present', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const inputBuffer = Buffer.from('export class MyComponent { static prop = 42; }', 'utf-8'); + const result = await transformer.transformData('src/component.js', inputBuffer, { + skipLinker: true, + }); + + expect(result).toBe(inputBuffer); + }); + + it('should dispatch to worker when Angular tokens are present', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const input = 'export class MyService { static ɵprov = true; }'; + const result = await transformer.transformData('src/service.js', input, { skipLinker: true }); + const text = Buffer.from(result).toString('utf-8'); + + expect(text).toContain('let MyService = /*#__PURE__*/ (() => {'); + }); + + it('should dispatch to worker when decorator tokens are present and sideEffects is false', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const inputBuffer = Buffer.from('const MyClass = __decorate([], class {});', 'utf-8'); + const result = await transformer.transformData('src/class.js', inputBuffer, { + skipLinker: true, + sideEffects: async () => false, + }); + + expect(result).not.toBe(inputBuffer); + }); + + it('should bypass worker and return converted buffer when no candidate tokens are present in string input', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const inputString = 'function multiply(a, b) { return a * b; }'; + const result = await transformer.transformData('src/math.js', inputString, { + skipLinker: true, + }); + + expect(Buffer.from(result).toString('utf-8')).toBe(inputString); + }); + + it('should dispatch to worker when candidate tokens are present in string input', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const inputString = 'export class MyService { static ɵprov = true; }'; + const result = await transformer.transformData('src/service.js', inputString, { + skipLinker: true, + }); + const text = Buffer.from(result).toString('utf-8'); + + expect(text).toContain('let MyService = /*#__PURE__*/ (() => {'); + }); + + it('should not query sideEffects when no candidate tokens are present', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + let queried = false; + const inputBuffer = Buffer.from('function add(a, b) { return a + b; }', 'utf-8'); + await transformer.transformData('src/math.js', inputBuffer, { + skipLinker: true, + sideEffects: async () => { + queried = true; + + return false; + }, + }); + + expect(queried).toBeFalse(); + }); + + it('should not query sideEffects when primary tokens are present', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + let queried = false; + const input = 'export class MyService { static ɵprov = true; }'; + await transformer.transformData('src/service.js', input, { + skipLinker: true, + sideEffects: async () => { + queried = true; + + return false; + }, + }); + + expect(queried).toBeFalse(); + }); + + it('should query sideEffects for @angular/ packages and dispatch to worker when sideEffects is false', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + let queryCount = 0; + const inputBuffer = Buffer.from('export const foo = someCall();', 'utf-8'); + const result = await transformer.transformData( + '/node_modules/@angular/core/fesm2022/index.mjs', + inputBuffer, + { + skipLinker: true, + sideEffects: async () => { + queryCount++; + + return false; + }, + }, + ); + + expect(queryCount).toBe(1); + expect(result).not.toBe(inputBuffer); + }); + + it('should query sideEffects for decorator tokens and evaluate at most once', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + let queryCount = 0; + const inputBuffer = Buffer.from('const MyClass = __decorate([], class {});', 'utf-8'); + const result = await transformer.transformData('src/class.js', inputBuffer, { + skipLinker: true, + sideEffects: async () => { + queryCount++; + + return false; + }, + }); + + expect(queryCount).toBe(1); + expect(result).not.toBe(inputBuffer); + }); + + it('should query sideEffects and wrap decorators when both primary and decorator tokens are present', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + let queryCount = 0; + const input = ` + let MyService = class MyService { static ɵprov = true; }; + MyService = __decorate([], MyService); + `; + const result = await transformer.transformData('src/service.js', input, { + skipLinker: true, + sideEffects: async () => { + queryCount++; + + return false; + }, + }); + + expect(queryCount).toBe(1); + const text = Buffer.from(result).toString('utf-8'); + expect(text).toContain('let MyService = /*#__PURE__*/ (() => {'); + expect(text).toContain('__decorate'); + }); + + it('should query sideEffects when both primary and decorator tokens are present in Buffer', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + let queryCount = 0; + const inputBuffer = Buffer.from( + 'let MyService = class MyService { static ɵprov = true; };\nMyService = __decorate([], MyService);', + 'utf-8', + ); + const result = await transformer.transformData('src/service.js', inputBuffer, { + skipLinker: true, + sideEffects: async () => { + queryCount++; + + return false; + }, + }); + + expect(queryCount).toBe(1); + const text = Buffer.from(result).toString('utf-8'); + expect(text).toContain('let MyService = /*#__PURE__*/ (() => {'); + expect(text).toContain('__decorate'); + }); + }); + + describe('maxConcurrency', () => { + it('should throw RangeError if maxConcurrency is less than 1', () => { + expect(() => { + new JavaScriptTransformer({ sourcemap: false, maxConcurrency: 0 }); + }).toThrowError( + RangeError, + 'options.maxConcurrency must be an integer greater than or equal to 1.', + ); + + expect(() => { + new JavaScriptTransformer({ sourcemap: false, maxConcurrency: -1 }); + }).toThrowError( + RangeError, + 'options.maxConcurrency must be an integer greater than or equal to 1.', + ); + }); + + it('should throw RangeError if maxConcurrency is not an integer', () => { + expect(() => { + new JavaScriptTransformer({ sourcemap: false, maxConcurrency: 1.5 }); + }).toThrowError( + RangeError, + 'options.maxConcurrency must be an integer greater than or equal to 1.', + ); + }); + + it('should allow omitting maxConcurrency', async () => { + transformer = new JavaScriptTransformer({ sourcemap: false }); + const result = await transformer.transformData('src/app.js', 'const x = 1;', { + skipLinker: true, + }); + expect(result).toBeDefined(); + }); + }); +}); diff --git a/packages/angular/build/src/tools/oxc/adjust-static-class-members_oxc_spec.ts b/packages/angular/build/src/tools/oxc/adjust-static-class-members_oxc_spec.ts new file mode 100644 index 000000000000..7fe8c68030fc --- /dev/null +++ b/packages/angular/build/src/tools/oxc/adjust-static-class-members_oxc_spec.ts @@ -0,0 +1,1103 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { transform } from './oxc-transform'; + +const NO_CHANGE = Symbol('NO_CHANGE'); + +function cleanCode(code: string): string { + return code.replace(/\s+/g, '').replace(/,([}\])])/g, '$1'); +} + +function testCase({ + input, + expected, + options, +}: { + input: string; + expected: string | typeof NO_CHANGE; + options?: { wrapDecorators?: boolean }; +}): jasmine.ImplementationCallback { + return async () => { + const result = transform('test.js', input, { + sourcemap: false, + sideEffects: options?.wrapDecorators ? false : true, + pureAnnotate: false, + }); + if (!result?.code) { + fail('Expected oxc-transform to return a transform result.'); + } else { + const actualClean = cleanCode(result.code); + const expectedClean = cleanCode(expected === NO_CHANGE ? input : expected); + expect(actualClean).toEqual(expectedClean); + } + }; +} + +describe('adjust-static-class-members oxc-transform implementation', () => { + it( + 'elides empty ctorParameters function expression static field', + testCase({ + input: ` + export class SomeClass {} + SomeClass.ctorParameters = function () { return []; }; + `, + expected: 'export class SomeClass {}', + }), + ); + + it( + 'elides non-empty ctorParameters function expression static field', + testCase({ + input: ` + export class SomeClass {} + SomeClass.ctorParameters = function () { return [{type: Injector}]; }; + `, + expected: 'export class SomeClass {}', + }), + ); + + it( + 'elides empty ctorParameters arrow expression static field', + testCase({ + input: ` + export class SomeClass {} + SomeClass.ctorParameters = () => []; + `, + expected: 'export class SomeClass {}', + }), + ); + + it( + 'elides non-empty ctorParameters arrow expression static field', + testCase({ + input: ` + export class SomeClass {} + SomeClass.ctorParameters = () => [{type: Injector}]; + `, + expected: 'export class SomeClass {}', + }), + ); + + it( + 'keeps ctorParameters static field without arrow/function expression', + testCase({ + input: ` + export class SomeClass {} + SomeClass.ctorParameters = 42; + `, + expected: ` + export let SomeClass = /*#__PURE__*/ (() => { + class SomeClass {} + SomeClass.ctorParameters = 42; + return SomeClass; + })(); + `, + }), + ); + + it( + 'elides empty decorators static field with array literal', + testCase({ + input: ` + export class SomeClass {} + SomeClass.decorators = []; + `, + expected: 'export class SomeClass {}', + }), + ); + + it( + 'elides non-empty decorators static field with array literal', + testCase({ + input: ` + export class SomeClass {} + SomeClass.decorators = [{ type: Injectable }]; + `, + expected: 'export class SomeClass {}', + }), + ); + + it( + 'keeps decorators static field without array literal', + testCase({ + input: ` + export class SomeClass {} + SomeClass.decorators = 42; + `, + expected: ` + export let SomeClass = /*#__PURE__*/ (() => { + class SomeClass {} + SomeClass.decorators = 42; + return SomeClass; + })(); + `, + }), + ); + + it( + 'elides empty propDecorators static field with object literal', + testCase({ + input: ` + export class SomeClass {} + SomeClass.propDecorators = {}; + `, + expected: 'export class SomeClass {}', + }), + ); + + it( + 'elides non-empty propDecorators static field with object literal', + testCase({ + input: ` + export class SomeClass {} + SomeClass.propDecorators = { 'ngIf': [{ type: Input }] }; + `, + expected: 'export class SomeClass {}', + }), + ); + + it( + 'keeps propDecorators static field without object literal', + testCase({ + input: ` + export class SomeClass {} + SomeClass.propDecorators = 42; + `, + expected: ` + export let SomeClass = /*#__PURE__*/ (() => { + class SomeClass {} + SomeClass.propDecorators = 42; + return SomeClass; + })(); + `, + }), + ); + + it( + 'does not wrap default exported class with no connected siblings', + testCase({ + // NOTE: This could technically have no changes but the default export splitting detection + // does not perform class property analysis currently. + input: ` + export default class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + `, + expected: ` + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + export { CustomComponentEffects as default }; + `, + }), + ); + + it( + 'does wrap not default exported class with only side effect fields', + testCase({ + input: ` + export default class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someFieldWithSideEffects = console.log('foo'); + `, + expected: NO_CHANGE, + }), + ); + + it( + 'does not wrap class with only side effect fields', + testCase({ + input: ` + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someFieldWithSideEffects = console.log('foo'); + `, + expected: NO_CHANGE, + }), + ); + + it( + 'does not wrap class with only side effect native fields', + testCase({ + input: ` + class CustomComponentEffects { + static someFieldWithSideEffects = console.log('foo'); + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + `, + expected: NO_CHANGE, + }), + ); + + it( + 'does not wrap class with only instance native fields', + testCase({ + input: ` + class CustomComponentEffects { + someFieldWithSideEffects = console.log('foo'); + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + `, + expected: NO_CHANGE, + }), + ); + + it( + 'wraps class with pure annotated side effect fields (#__PURE__)', + testCase({ + input: ` + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someFieldWithSideEffects = /*#__PURE__*/ console.log('foo'); + `, + expected: ` + let CustomComponentEffects = /*#__PURE__*/ (() => { + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someFieldWithSideEffects = /*#__PURE__*/ console.log('foo'); + return CustomComponentEffects; + })(); + `, + }), + ); + + it( + 'wraps class with pure annotated side effect native fields (#__PURE__)', + testCase({ + input: ` + class CustomComponentEffects { + static someFieldWithSideEffects = /*#__PURE__*/ console.log('foo'); + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + `, + expected: ` + let CustomComponentEffects = /*#__PURE__*/ (() => { + class CustomComponentEffects { + static someFieldWithSideEffects = /*#__PURE__*/ console.log('foo'); + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + return CustomComponentEffects; + })(); + `, + }), + ); + + it( + 'wraps class with pure annotated side effect fields (@__PURE__)', + testCase({ + input: ` + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someFieldWithSideEffects = /*@__PURE__*/ console.log('foo'); + `, + expected: ` + let CustomComponentEffects = /*#__PURE__*/ (() => { + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someFieldWithSideEffects = /*@__PURE__*/ console.log('foo'); + return CustomComponentEffects; + })(); + `, + }), + ); + + it( + 'wraps class with pure annotated side effect fields (@pureOrBreakMyCode)', + testCase({ + input: ` + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someFieldWithSideEffects = /**@pureOrBreakMyCode*/ console.log('foo'); + `, + expected: ` + let CustomComponentEffects = /*#__PURE__*/ (() => { + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someFieldWithSideEffects = + /**@pureOrBreakMyCode*/ console.log('foo'); + return CustomComponentEffects; + })(); + `, + }), + ); + + it( + 'wraps class with closure pure annotated side effect fields', + testCase({ + input: ` + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someFieldWithSideEffects = /* @pureOrBreakMyCode */ console.log('foo'); + `, + expected: ` + let CustomComponentEffects = /*#__PURE__*/ (() => { + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someFieldWithSideEffects = + /* @pureOrBreakMyCode */ console.log('foo'); + return CustomComponentEffects; + })(); + `, + }), + ); + + it( + 'wraps exported class with a pure static field', + testCase({ + input: ` + export class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someField = 42; + `, + expected: ` + export let CustomComponentEffects = /*#__PURE__*/ (() => { + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someField = 42; + return CustomComponentEffects; + })(); + `, + }), + ); + + it( + 'wraps exported class with a pure native static field', + testCase({ + input: ` + export class CustomComponentEffects { + static someField = 42; + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + `, + expected: ` + export let CustomComponentEffects = /*#__PURE__*/ (() => { + class CustomComponentEffects { + static someField = 42; + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + return CustomComponentEffects; + })(); + `, + }), + ); + + it( + 'wraps class with a basic literal static field', + testCase({ + input: ` + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someField = 42; + `, + expected: ` + let CustomComponentEffects = /*#__PURE__*/ (() => { + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someField = 42; + return CustomComponentEffects; + })(); + `, + }), + ); + + it( + 'wraps class with a pure static field', + testCase({ + input: ` + const SWITCH_TEMPLATE_REF_FACTORY__POST_R3__ = injectTemplateRef; + const SWITCH_TEMPLATE_REF_FACTORY = SWITCH_TEMPLATE_REF_FACTORY__POST_R3__; + class TemplateRef {} + TemplateRef.__NG_ELEMENT_ID__ = SWITCH_TEMPLATE_REF_FACTORY; + `, + expected: ` + const SWITCH_TEMPLATE_REF_FACTORY__POST_R3__ = injectTemplateRef; + const SWITCH_TEMPLATE_REF_FACTORY = SWITCH_TEMPLATE_REF_FACTORY__POST_R3__; + let TemplateRef = /*#__PURE__*/ (() => { + class TemplateRef {} + TemplateRef.__NG_ELEMENT_ID__ = SWITCH_TEMPLATE_REF_FACTORY; + return TemplateRef; + })(); + `, + }), + ); + + it( + 'wraps class with multiple pure static field', + testCase({ + input: ` + const SWITCH_TEMPLATE_REF_FACTORY__POST_R3__ = injectTemplateRef; + const SWITCH_TEMPLATE_REF_FACTORY = SWITCH_TEMPLATE_REF_FACTORY__POST_R3__; + class TemplateRef {} + TemplateRef.__NG_ELEMENT_ID__ = SWITCH_TEMPLATE_REF_FACTORY; + TemplateRef.someField = 42; + `, + expected: ` + const SWITCH_TEMPLATE_REF_FACTORY__POST_R3__ = injectTemplateRef; + const SWITCH_TEMPLATE_REF_FACTORY = SWITCH_TEMPLATE_REF_FACTORY__POST_R3__; + let TemplateRef = /*#__PURE__*/ (() => { + class TemplateRef {} + TemplateRef.__NG_ELEMENT_ID__ = SWITCH_TEMPLATE_REF_FACTORY; + TemplateRef.someField = 42; + return TemplateRef; + })(); + `, + }), + ); + + it( + 'does not wrap class with only some pure static fields', + testCase({ + input: ` + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someField = 42; + CustomComponentEffects.someFieldWithSideEffects = console.log('foo'); + `, + expected: NO_CHANGE, + }), + ); + + it( + 'does not wrap class with only pure native static fields and some side effect static fields', + testCase({ + input: ` + class CustomComponentEffects { + static someField = 42; + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someFieldWithSideEffects = console.log('foo'); + `, + expected: NO_CHANGE, + }), + ); + + it( + 'does not wrap class with only some pure native static fields', + testCase({ + input: ` + class CustomComponentEffects { + static someField = 42; + static someFieldWithSideEffects = console.log('foo'); + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + `, + expected: NO_CHANGE, + }), + ); + + it( + 'does not wrap class with class decorators when wrapDecorators is false', + testCase({ + input: ` + let SomeClass = class SomeClass { + }; + SomeClass = __decorate([ + Dec() + ], SomeClass); + `, + expected: NO_CHANGE, + options: { wrapDecorators: false }, + }), + ); + + it( + 'wraps class with Angular ɵfac static field (esbuild)', + testCase({ + input: ` + var Comp2Component = class _Comp2Component { + static { + this.ɵfac = function Comp2Component_Factory(t) { + return new (t || _Comp2Component)(); + }; + } + }; + `, + expected: ` + var Comp2Component = /*#__PURE__*/ (() => { + let Comp2Component = class _Comp2Component { + static { + this.ɵfac = function Comp2Component_Factory(t) { + return new (t || _Comp2Component)(); + }; + } + }; + return Comp2Component; + })(); + `, + }), + ); + + it( + 'wraps class with class decorators when wrapDecorators is true (esbuild output)', + testCase({ + input: ` + var ExampleClass = class { + method() { + } + }; + __decorate([ + SomeDecorator() + ], ExampleClass.prototype, "method", null); + `, + expected: ` + var ExampleClass = /*#__PURE__*/ (() => { + let ExampleClass = class { + method() {} + }; + __decorate([SomeDecorator()], ExampleClass.prototype, "method", null); + return ExampleClass; + })(); + `, + options: { wrapDecorators: true }, + }), + ); + + it( + 'wraps class with class decorators when wrapDecorators is true', + testCase({ + input: ` + let SomeClass = class SomeClass { + }; + SomeClass = __decorate([ + SomeDecorator() + ], SomeClass); + `, + expected: ` + let SomeClass = /*#__PURE__*/ (() => { + let SomeClass = class SomeClass { + }; + SomeClass = __decorate([ + SomeDecorator() + ], SomeClass); + return SomeClass; + })(); + `, + options: { wrapDecorators: true }, + }), + ); + + it( + 'does not wrap class with constructor decorators when wrapDecorators is false', + testCase({ + input: ` + let SomeClass = class SomeClass { + constructor(foo) { } + }; + SomeClass = __decorate([ + __param(0, SomeDecorator) + ], SomeClass); + `, + expected: NO_CHANGE, + options: { wrapDecorators: false }, + }), + ); + + it( + 'wraps class with constructor decorators when wrapDecorators is true', + testCase({ + input: ` + let SomeClass = class SomeClass { + constructor(foo) { } + }; + SomeClass = __decorate([ + __param(0, SomeDecorator) + ], SomeClass); + `, + expected: ` + let SomeClass = /*#__PURE__*/ (() => { + let SomeClass = class SomeClass { + constructor(foo) { } + }; + SomeClass = __decorate([ + __param(0, SomeDecorator) + ], SomeClass); + return SomeClass; + })(); + `, + options: { wrapDecorators: true }, + }), + ); + + it( + 'does not wrap class with field decorators when wrapDecorators is false', + testCase({ + input: ` + class SomeClass { + constructor() { + this.foo = 42; + } + } + __decorate([ + SomeDecorator + ], SomeClass.prototype, "foo", void 0); + `, + expected: NO_CHANGE, + options: { wrapDecorators: false }, + }), + ); + + it( + 'wraps class with field decorators when wrapDecorators is true', + testCase({ + input: ` + class SomeClass { + constructor() { + this.foo = 42; + } + } + __decorate([ + SomeDecorator + ], SomeClass.prototype, "foo", void 0); + `, + expected: ` + let SomeClass = /*#__PURE__*/ (() => { + class SomeClass { + constructor() { + this.foo = 42; + } + } + __decorate([ + SomeDecorator + ], SomeClass.prototype, "foo", void 0); + return SomeClass; + })(); + `, + options: { wrapDecorators: true }, + }), + ); + + it( + 'wraps class with Angular ɵfac static field', + testCase({ + input: ` + class CommonModule { + } + CommonModule.ɵfac = function CommonModule_Factory(t) { return new (t || CommonModule)(); }; + `, + expected: ` + let CommonModule = /*#__PURE__*/ (() => { + class CommonModule { + } + CommonModule.ɵfac = function CommonModule_Factory(t) { return new (t || CommonModule)(); }; + return CommonModule; + })(); + `, + }), + ); + + it( + 'wraps class with Angular ɵfac static block (ES2022 + useDefineForClassFields: false)', + testCase({ + input: ` + class CommonModule { + static { this.ɵfac = function CommonModule_Factory(t) { return new (t || CommonModule)(); }; } + static { this.ɵmod = ɵngcc0.ɵɵdefineNgModule({ type: CommonModule }); } + } + `, + expected: ` + let CommonModule = /*#__PURE__*/ (() => { + class CommonModule { + static { + this.ɵfac = function CommonModule_Factory(t) { + return new (t || CommonModule)(); + }; + } + static { + this.ɵmod = ɵngcc0.ɵɵdefineNgModule({ + type: CommonModule, + }); + } + } + return CommonModule; + })(); + `, + }), + ); + + it( + 'does not wrap class with side effect full static block (ES2022 + useDefineForClassFields: false)', + testCase({ + input: ` + class CommonModule { + static { globalThis.bar = 1 } + } + `, + expected: NO_CHANGE, + }), + ); + + it( + 'wraps class with Angular ɵmod static field', + testCase({ + input: ` + class CommonModule { + } + CommonModule.ɵmod = /*@__PURE__*/ ɵngcc0.ɵɵdefineNgModule({ type: CommonModule }); + `, + expected: ` + let CommonModule = /*#__PURE__*/ (() => { + class CommonModule { + } + CommonModule.ɵmod = /*@__PURE__*/ ɵngcc0.ɵɵdefineNgModule({ type: CommonModule }); + return CommonModule; + })(); + `, + }), + ); + + it( + 'wraps class with Angular ɵinj static field', + testCase({ + input: ` + class CommonModule { + } + CommonModule.ɵinj = /*@__PURE__*/ ɵngcc0.ɵɵdefineInjector({ providers: [ + { provide: NgLocalization, useClass: NgLocaleLocalization }, + ] }); + `, + expected: ` + let CommonModule = /*#__PURE__*/ (() => { + class CommonModule { + } + CommonModule.ɵinj = /*@__PURE__*/ ɵngcc0.ɵɵdefineInjector({ providers: [ + { + provide: NgLocalization, + useClass: NgLocaleLocalization + }, + ] }); + return CommonModule; + })(); + `, + }), + ); + + it( + 'wraps class with multiple Angular static fields', + testCase({ + input: ` + class CommonModule { + } + CommonModule.ɵfac = function CommonModule_Factory(t) { return new (t || CommonModule)(); }; + CommonModule.ɵmod = /*@__PURE__*/ ɵngcc0.ɵɵdefineNgModule({ type: CommonModule }); + CommonModule.ɵinj = /*@__PURE__*/ ɵngcc0.ɵɵdefineInjector({ providers: [ + { provide: NgLocalization, useClass: NgLocaleLocalization }, + ] }); + `, + expected: ` + let CommonModule = /*#__PURE__*/ (() => { + class CommonModule { + } + CommonModule.ɵfac = function CommonModule_Factory(t) { return new (t || CommonModule)(); }; + CommonModule.ɵmod = /*@__PURE__*/ ɵngcc0.ɵɵdefineNgModule({ type: CommonModule }); + CommonModule.ɵinj = /*@__PURE__*/ ɵngcc0.ɵɵdefineInjector({ providers: [ + { + provide: NgLocalization, + useClass: NgLocaleLocalization + }, + ]}); + return CommonModule; + })(); + `, + }), + ); + + it( + 'wraps class with multiple Angular native static fields', + testCase({ + input: ` + class CommonModule { + static ɵfac = function CommonModule_Factory(t) { return new (t || CommonModule)(); }; + static ɵmod = /*@__PURE__*/ ɵngcc0.ɵɵdefineNgModule({ type: CommonModule }); + static ɵinj = ɵngcc0.ɵɵdefineInjector({ providers: [ + { provide: NgLocalization, useClass: NgLocaleLocalization }, + ] }); + } + `, + expected: ` + let CommonModule = /*#__PURE__*/ (() => { + class CommonModule { + static ɵfac = function CommonModule_Factory(t) { + return new (t || CommonModule)(); + }; + static ɵmod = /*@__PURE__*/ ɵngcc0.ɵɵdefineNgModule({ + type: CommonModule, + }); + static ɵinj = ɵngcc0.ɵɵdefineInjector({ + providers: [ + { + provide: NgLocalization, + useClass: NgLocaleLocalization, + }, + ], + }); + } + return CommonModule; + })(); + `, + }), + ); + + it( + 'wraps default exported class with pure static fields', + testCase({ + input: ` + export default class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someField = 42; + `, + expected: ` + let CustomComponentEffects = /*#__PURE__*/ (() => { + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someField = 42; + return CustomComponentEffects; + })(); + export { CustomComponentEffects as default }; + `, + }), + ); + it( + 'supports class with empty static block and wraps pure static properties', + testCase({ + input: ` + class MyClass { + static {} + static ɵprov = 42; + } + `, + expected: ` + let MyClass = /*#__PURE__*/ (() => { + class MyClass { + static {} + static ɵprov = 42; + } + return MyClass; + })(); + `, + }), + ); + + it( + 'does not wrap class with non-empty static block', + testCase({ + input: ` + class MyClass { + static { console.log(1); } + static ɵprov = 42; + } + `, + expected: ` + class MyClass { + static { console.log(1); } + static ɵprov = 42; + } + `, + }), + ); + + it( + 'wraps adjacent class declarations without interleaving', + testCase({ + input: 'class A{static s=[1]}class B{static s=[2]}', + expected: ` + let A = /*#__PURE__*/ (() => { + class A { + static s = [1] + } + return A; + })(); + let B = /*#__PURE__*/ (() => { + class B { + static s = [2] + } + return B; + })(); + `, + }), + ); + + it( + 'wraps adjacent exported class declarations without interleaving', + testCase({ + input: 'export class A{static s=[1]}export class B{static s=[2]}', + expected: ` + export let A = /*#__PURE__*/ (() => { + class A { + static s = [1] + } + return A; + })(); + export let B = /*#__PURE__*/ (() => { + class B { + static s = [2] + } + return B; + })(); + `, + }), + ); + + it( + 'wraps adjacent default export and class declarations without interleaving', + testCase({ + input: 'export default class A{static s=[1]}class B{static s=[2]}', + expected: ` + let A = /*#__PURE__*/ (() => { + class A { + static s = [1] + } + return A; + })(); + export { A as default }; + let B = /*#__PURE__*/ (() => { + class B { + static s = [2] + } + return B; + })(); + `, + }), + ); + + it( + 'splits default export without interleaving with adjacent class declaration', + testCase({ + input: 'export default class A{}class B{static s=[2]}', + expected: ` + class A {} + export { A as default }; + let B = /*#__PURE__*/ (() => { + class B { + static s = [2] + } + return B; + })(); + `, + }), + ); + + it( + 'wraps adjacent variable class declarations without interleaving', + testCase({ + input: 'let A=class A{static s=[1]};class B{static s=[2]}', + expected: ` + let A = /*#__PURE__*/ (() => { + let A = class A { + static s = [1] + }; + return A; + })(); + let B = /*#__PURE__*/ (() => { + class B { + static s = [2] + } + return B; + })(); + `, + }), + ); +}); diff --git a/packages/angular/build/src/tools/oxc/adjust-typescript-enums_oxc_spec.ts b/packages/angular/build/src/tools/oxc/adjust-typescript-enums_oxc_spec.ts new file mode 100644 index 000000000000..82877cb3b2e3 --- /dev/null +++ b/packages/angular/build/src/tools/oxc/adjust-typescript-enums_oxc_spec.ts @@ -0,0 +1,382 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { format } from 'prettier'; +import { transform } from './oxc-transform'; + +const NO_CHANGE = Symbol('NO_CHANGE'); + +function testCase({ + input, + expected, +}: { + input: string; + expected: string | typeof NO_CHANGE; +}): jasmine.ImplementationCallback { + return async () => { + const result = transform('test.js', input, { + sourcemap: false, + pureAnnotate: false, + }); + if (!result?.code) { + fail('Expected oxc-transform to return a transform result.'); + } else { + expect(await format(result.code, { parser: 'babel' })).toEqual( + await format(expected === NO_CHANGE ? input : expected, { parser: 'babel' }), + ); + } + }; +} + +describe('adjust-typescript-enums oxc-transform implementation', () => { + it( + 'wraps unexported TypeScript enums', + testCase({ + input: ` + var ChangeDetectionStrategy; + (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush"; + ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default"; + })(ChangeDetectionStrategy || (ChangeDetectionStrategy = {})); + `, + expected: ` + var ChangeDetectionStrategy = /*#__PURE__*/ (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[(ChangeDetectionStrategy["OnPush"] = 0)] = "OnPush"; + ChangeDetectionStrategy[(ChangeDetectionStrategy["Default"] = 1)] = "Default"; + return ChangeDetectionStrategy; + })(ChangeDetectionStrategy || {}); + `, + }), + ); + + it( + 'wraps exported TypeScript enums', + testCase({ + input: ` + export var ChangeDetectionStrategy; + (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush"; + ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default"; + })(ChangeDetectionStrategy || (ChangeDetectionStrategy = {})); + `, + expected: ` + export var ChangeDetectionStrategy = /*#__PURE__*/ (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[(ChangeDetectionStrategy["OnPush"] = 0)] = "OnPush"; + ChangeDetectionStrategy[(ChangeDetectionStrategy["Default"] = 1)] = "Default"; + return ChangeDetectionStrategy; + })(ChangeDetectionStrategy || {}); + `, + }), + ); + + it( + 'does not wrap exported TypeScript enums from CommonJS (<5.1)', + testCase({ + input: ` + var ChangeDetectionStrategy; + (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush"; + ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default"; + })(ChangeDetectionStrategy = exports.ChangeDetectionStrategy || (exports.ChangeDetectionStrategy = {})); + `, + expected: NO_CHANGE, + }), + ); + + it( + 'wraps exported TypeScript enums from CommonJS (5.1+)', + testCase({ + input: ` + var ChangeDetectionStrategy; + (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush"; + ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default"; + })(ChangeDetectionStrategy || (exports.ChangeDetectionStrategy = ChangeDetectionStrategy = {})); + `, + expected: ` + var ChangeDetectionStrategy = /*#__PURE__*/ (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[(ChangeDetectionStrategy["OnPush"] = 0)] = "OnPush"; + ChangeDetectionStrategy[(ChangeDetectionStrategy["Default"] = 1)] = "Default"; + return ChangeDetectionStrategy; + })(ChangeDetectionStrategy || (exports.ChangeDetectionStrategy = ChangeDetectionStrategy = {})); + `, + }), + ); + + it( + 'wraps TypeScript enums with custom numbering', + testCase({ + input: ` + export var ChangeDetectionStrategy; + (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 5] = "OnPush"; + ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 8] = "Default"; + })(ChangeDetectionStrategy || (ChangeDetectionStrategy = {})); + `, + expected: ` + export var ChangeDetectionStrategy = /*#__PURE__*/ (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[(ChangeDetectionStrategy["OnPush"] = 5)] = "OnPush"; + ChangeDetectionStrategy[(ChangeDetectionStrategy["Default"] = 8)] = "Default"; + return ChangeDetectionStrategy; + })(ChangeDetectionStrategy || {}); + `, + }), + ); + + it( + 'wraps string-based TypeScript enums', + testCase({ + input: ` + var NotificationKind; + (function (NotificationKind) { + NotificationKind["NEXT"] = "N"; + NotificationKind["ERROR"] = "E"; + NotificationKind["COMPLETE"] = "C"; + })(NotificationKind || (NotificationKind = {})); + `, + expected: ` + var NotificationKind = /*#__PURE__*/ (function (NotificationKind) { + NotificationKind["NEXT"] = "N"; + NotificationKind["ERROR"] = "E"; + NotificationKind["COMPLETE"] = "C"; + return NotificationKind; + })(NotificationKind || {}); + `, + }), + ); + + it( + 'wraps enums that were renamed due to scope hoisting', + testCase({ + input: ` + var NotificationKind$1; + (function (NotificationKind) { + NotificationKind["NEXT"] = "N"; + NotificationKind["ERROR"] = "E"; + NotificationKind["COMPLETE"] = "C"; + })(NotificationKind$1 || (NotificationKind$1 = {})); + `, + expected: ` + var NotificationKind$1 = /*#__PURE__*/ (function (NotificationKind) { + NotificationKind["NEXT"] = "N"; + NotificationKind["ERROR"] = "E"; + NotificationKind["COMPLETE"] = "C"; + return NotificationKind; + })(NotificationKind$1 || {}); + `, + }), + ); + + it( + 'maintains multi-line comments', + testCase({ + input: ` + /** + * Supported http methods. + * @deprecated use @angular/common/http instead + */ + var RequestMethod; + (function (RequestMethod) { + RequestMethod[RequestMethod["Get"] = 0] = "Get"; + RequestMethod[RequestMethod["Post"] = 1] = "Post"; + RequestMethod[RequestMethod["Put"] = 2] = "Put"; + RequestMethod[RequestMethod["Delete"] = 3] = "Delete"; + RequestMethod[RequestMethod["Options"] = 4] = "Options"; + RequestMethod[RequestMethod["Head"] = 5] = "Head"; + RequestMethod[RequestMethod["Patch"] = 6] = "Patch"; + })(RequestMethod || (RequestMethod = {})); + `, + expected: ` + /** + * Supported http methods. + * @deprecated use @angular/common/http instead + */ + var RequestMethod = /*#__PURE__*/ (function (RequestMethod) { + RequestMethod[(RequestMethod["Get"] = 0)] = "Get"; + RequestMethod[(RequestMethod["Post"] = 1)] = "Post"; + RequestMethod[(RequestMethod["Put"] = 2)] = "Put"; + RequestMethod[(RequestMethod["Delete"] = 3)] = "Delete"; + RequestMethod[(RequestMethod["Options"] = 4)] = "Options"; + RequestMethod[(RequestMethod["Head"] = 5)] = "Head"; + RequestMethod[(RequestMethod["Patch"] = 6)] = "Patch"; + return RequestMethod; + })(RequestMethod || {}); + `, + }), + ); + + it( + 'does not wrap TypeScript enums with side effect values', + testCase({ + input: ` + export var ChangeDetectionStrategy; + (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = console.log('foo'); + ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default"; + })(ChangeDetectionStrategy || (ChangeDetectionStrategy = {})); + `, + expected: NO_CHANGE, + }), + ); + + it( + 'does not wrap object literals similar to TypeScript enums', + testCase({ + input: ` + const RendererStyleFlags3 = { + Important: 1, + DashCase: 2, + }; + if (typeof RendererStyleFlags3 === 'object') { + RendererStyleFlags3[RendererStyleFlags3.Important] = 'DashCase'; + } + RendererStyleFlags3[RendererStyleFlags3.Important] = 'Important'; + `, + expected: NO_CHANGE, + }), + ); + + it( + 'wraps TypeScript enums', + testCase({ + input: ` + var ChangeDetectionStrategy; + (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush"; + ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default"; + })(ChangeDetectionStrategy || (ChangeDetectionStrategy = {})); + `, + expected: ` + var ChangeDetectionStrategy = /*#__PURE__*/ (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[(ChangeDetectionStrategy["OnPush"] = 0)] = "OnPush"; + ChangeDetectionStrategy[(ChangeDetectionStrategy["Default"] = 1)] = "Default"; + return ChangeDetectionStrategy; + })(ChangeDetectionStrategy || {}); + `, + }), + ); + + it( + 'should wrap TypeScript enums if the declaration identifier has been renamed to avoid collisions', + testCase({ + input: ` + var ChangeDetectionStrategy$1; + (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush"; + ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default"; + })(ChangeDetectionStrategy$1 || (ChangeDetectionStrategy$1 = {})); + `, + expected: ` + var ChangeDetectionStrategy$1 = /*#__PURE__*/ (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush"; + ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default"; + return ChangeDetectionStrategy; + })(ChangeDetectionStrategy$1 || {}); + `, + }), + ); + + it( + 'handles TypeScript enums with chained exports assignment (angular-split / shared-docs pattern)', + testCase({ + input: ` + var Area; + (function (a1) { + a1[a1["areaAfter"] = 0] = "areaAfter"; + a1[a1["preserveOtherCategoryOrder"] = 1] = "preserveOtherCategoryOrder"; + })(Area || (Area = exports.Area = {})); + `, + expected: ` + var Area = /*#__PURE__*/ (function (a1) { + a1[(a1["areaAfter"] = 0)] = "areaAfter"; + a1[(a1["preserveOtherCategoryOrder"] = 1)] = "preserveOtherCategoryOrder"; + return a1; + })(Area || (exports.Area = {})); + `, + }), + ); + + it( + 'handles TypeScript enums wrapped in parentheses', + testCase({ + input: ` + var ChangeDetectionStrategy; + ((function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush"; + ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default"; + })(ChangeDetectionStrategy || (ChangeDetectionStrategy = {}))); + `, + expected: ` + var ChangeDetectionStrategy = /*#__PURE__*/ (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[(ChangeDetectionStrategy["OnPush"] = 0)] = "OnPush"; + ChangeDetectionStrategy[(ChangeDetectionStrategy["Default"] = 1)] = "Default"; + return ChangeDetectionStrategy; + })(ChangeDetectionStrategy || {}); + `, + }), + ); + + it( + 'wraps Crockford-style TypeScript enum IIFE without leaving dangling parentheses', + testCase({ + input: ` + var HDirection; + (function (HDirection) { + HDirection[HDirection['Backwards'] = -1] = 'Backwards'; + HDirection[HDirection['Forwards'] = 1] = 'Forwards'; + }(HDirection || (HDirection = {}))); + const nextStatement = true; + `, + expected: ` + var HDirection = /*#__PURE__*/ (function (HDirection) { + HDirection[(HDirection['Backwards'] = -1)] = 'Backwards'; + HDirection[(HDirection['Forwards'] = 1)] = 'Forwards'; + return HDirection; + }(HDirection || {})); + + const nextStatement = true; + `, + }), + ); + + it( + 'wraps TypeScript enum IIFE with multiple nested parentheses', + testCase({ + input: ` + var Foo; + (((function (Foo) { + Foo[Foo['A'] = 0] = 'A'; + }(Foo || (Foo = {}))))); + `, + expected: ` + var Foo = /*#__PURE__*/ (function (Foo) { + Foo[(Foo['A'] = 0)] = 'A'; + return Foo; + }(Foo || {})); + `, + }), + ); + + it( + 'wraps Crockford-style TypeScript enum IIFE with chained export assignments', + testCase({ + input: ` + var Foo; + (function (Foo) { + Foo[Foo['A'] = 0] = 'A'; + }(Foo || (Foo = exports.Foo = {}))); + `, + expected: ` + var Foo = /*#__PURE__*/ (function (Foo) { + Foo[(Foo['A'] = 0)] = 'A'; + return Foo; + }(Foo || (exports.Foo = {}))); + `, + }), + ); +}); diff --git a/packages/angular/build/src/tools/oxc/elide-angular-metadata_oxc_spec.ts b/packages/angular/build/src/tools/oxc/elide-angular-metadata_oxc_spec.ts new file mode 100644 index 000000000000..f4d6c09b6c19 --- /dev/null +++ b/packages/angular/build/src/tools/oxc/elide-angular-metadata_oxc_spec.ts @@ -0,0 +1,213 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { transform } from './oxc-transform'; + +function cleanCode(code: string): string { + return code + .replace(/\s+/g, '') + .replace(/"/g, "'") + .replace(/;}/g, '}') + .replace(/,([}\])])/g, '$1'); +} + +function testCase({ + input, + expected, +}: { + input: string; + expected: string; +}): jasmine.ImplementationCallback { + return async () => { + const result = transform('test.js', input, { + sourcemap: false, + pureAnnotate: false, + }); + if (!result?.code) { + fail('Expected oxc-transform to return a transform result.'); + } else { + const actualClean = cleanCode(result.code); + const expectedClean = cleanCode(expected); + expect(actualClean).toEqual(expectedClean); + } + }; +} + +describe('elide-angular-metadata oxc-transform implementation', () => { + it( + 'elides pure annotated ɵsetClassMetadata', + testCase({ + input: ` + import { Component } from '@angular/core'; + export class SomeClass {} + /*@__PURE__*/ (function () { i0.ɵsetClassMetadata(Clazz, [{ + type: Component, + args: [{ + selector: 'app-lazy', + template: 'very lazy', + styles: [] + }] + }], null, null); })(); + `, + expected: ` + import { Component } from '@angular/core'; + export class SomeClass {} + /*@__PURE__*/ (function () { void 0 })(); + `, + }), + ); + + it( + 'elides JIT mode protected ɵsetClassMetadata', + testCase({ + input: ` + import { Component } from '@angular/core'; + export class SomeClass {} + (function () { (typeof ngJitMode === "undefined" || ngJitMode) && i0.ɵsetClassMetadata(SomeClass, [{ + type: Component, + args: [{ + selector: 'app-lazy', + template: 'very lazy', + styles: [] + }] + }], null, null); })();`, + expected: ` + import { Component } from '@angular/core'; + export class SomeClass {} + (function () { (typeof ngJitMode === "undefined" || ngJitMode) && void 0 })();`, + }), + ); + + it( + 'elides ɵsetClassMetadata inside an arrow-function-based IIFE', + testCase({ + input: ` + import { Component } from '@angular/core'; + export class SomeClass {} + /*@__PURE__*/ (() => { i0.ɵsetClassMetadata(Clazz, [{ + type: Component, + args: [{ + selector: 'app-lazy', + template: 'very lazy', + styles: [] + }] + }], null, null); })(); + `, + expected: ` + import { Component } from '@angular/core'; + export class SomeClass {} + /*@__PURE__*/ (() => { void 0 })(); + `, + }), + ); + + it( + 'elides pure annotated ɵsetClassMetadataAsync', + testCase({ + input: ` + import { Component } from '@angular/core'; + export class SomeClass {} + /*@__PURE__*/ (function () { + i0.ɵsetClassMetadataAsync(SomeClass, + function () { return [import("./cmp-a").then(function (m) { return m.CmpA; })]; }, + function (CmpA) { i0.ɵsetClassMetadata(SomeClass, [{ + type: Component, + args: [{ + selector: 'test-cmp', + standalone: true, + imports: [CmpA, LocalDep], + template: '{#defer}{/defer}', + }] + }], null, null); }); + })(); + `, + expected: ` + import { Component } from '@angular/core'; + export class SomeClass {} + /*@__PURE__*/ (function () { void 0 })(); + `, + }), + ); + + it( + 'elides JIT mode protected ɵsetClassMetadataAsync', + testCase({ + input: ` + import { Component } from '@angular/core'; + export class SomeClass {} + (function () { + (typeof ngJitMode === "undefined" || ngJitMode) && i0.ɵsetClassMetadataAsync(SomeClass, + function () { return [import("./cmp-a").then(function (m) { return m.CmpA; })]; }, + function (CmpA) { i0.ɵsetClassMetadata(SomeClass, [{ + type: Component, + args: [{ + selector: 'test-cmp', + standalone: true, + imports: [CmpA, LocalDep], + template: '{#defer}{/defer}', + }] + }], null, null); }); + })(); + `, + expected: ` + import { Component } from '@angular/core'; + export class SomeClass {} + (function () { (typeof ngJitMode === "undefined" || ngJitMode) && void 0 })(); + `, + }), + ); + + it( + 'elides arrow-function-based ɵsetClassMetadataAsync', + testCase({ + input: ` + import { Component } from '@angular/core'; + export class SomeClass {} + /*@__PURE__*/ (() => { + i0.ɵsetClassMetadataAsync(SomeClass, + () => [import("./cmp-a").then(m => m.CmpA)], + (CmpA) => { i0.ɵsetClassMetadata(SomeClass, [{ + type: Component, + args: [{ + selector: 'test-cmp', + standalone: true, + imports: [CmpA, LocalDep], + template: '{#defer}{/defer}', + }] + }], null, null); }); + })(); + `, + expected: ` + import { Component } from '@angular/core'; + export class SomeClass {} + /*@__PURE__*/ (() => { void 0 })(); + `, + }), + ); + + it( + 'elides ɵsetClassDebugInfo', + testCase({ + input: ` + import { Component } from '@angular/core'; + class SomeClass {} + (() => { + (typeof ngDevMode === 'undefined' || ngDevMode) && + i0.ɵsetClassDebugInfo(SomeClass, { className: 'SomeClass' }); + })(); + `, + expected: ` + import { Component } from "@angular/core"; + class SomeClass {} + (() => { + (typeof ngDevMode === "undefined" || ngDevMode) && void 0; + })(); + `, + }), + ); +}); diff --git a/packages/angular/build/src/tools/oxc/oxc-transform.ts b/packages/angular/build/src/tools/oxc/oxc-transform.ts new file mode 100644 index 000000000000..e0d6ba26a252 --- /dev/null +++ b/packages/angular/build/src/tools/oxc/oxc-transform.ts @@ -0,0 +1,748 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { DecodedSourceMap } from '@ampproject/remapping'; +import { needsLinking } from '@angular/compiler-cli/linker'; +import type { BindingIdentifier, Class, Node } from '@oxc-project/types'; +import { MagicString } from 'magic-string'; +import { parseSync } from 'oxc-parser'; +import { OxcLinker } from '../angular/linker/oxc-linker'; +import { traversePostOrder } from './traversal'; + +export interface OxcTransformOptions { + sourcemap?: boolean; + sideEffects?: boolean; + topLevelSafeMode?: boolean; + pureAnnotate?: boolean; + link?: boolean; + jit?: boolean; + advancedOptimizations?: boolean; +} + +/** + * A set of constructor names that are considered to be side-effect free. + */ +const sideEffectFreeConstructors = new Set(['InjectionToken']); + +/** + * A set of TypeScript helper function names used by the helper name matcher utility function. + */ +const tslibHelpers = new Set([ + '__extends', + '__assign', + '__rest', + '__decorate', + '__param', + '__esDecorate', + '__runInitializers', + '__propKey', + '__setFunctionName', + '__metadata', + '__awaiter', + '__generator', + '__exportStar', + '__values', + '__read', + '__privateGet', + '__privateSet', + '__privateMethod', + '__addDisposableResource', + '__disposeResources', +]); + +/** + * Determines whether an identifier name matches one of the TypeScript helper function names. + * + * @param name The identifier name to check. + * @returns True if the name matches a TypeScript helper name; otherwise, false. + */ +function isTslibHelperName(name: string): boolean { + const nameParts = name.split('$'); + const originalName = nameParts[0]; + + if (nameParts.length > 2 || (nameParts.length === 2 && !/^\d+$/.test(nameParts[1]))) { + return false; + } + + return tslibHelpers.has(originalName); +} + +/** + * A set of Babel helper function names that are intended to cause side effects. + */ +const babelHelpers = new Set(['_defineProperty']); + +/** + * Determines whether an identifier name matches one of the Babel helper function names. + * + * @param name The identifier name to check. + * @returns True if the name matches a Babel helper name; otherwise, false. + */ +function isBabelHelperName(name: string): boolean { + return babelHelpers.has(name); +} + +/** + * A set of Angular static properties that should be wrapped in pure IIFE statements. + */ +const angularStaticsToWrap = new Set([ + 'ɵcmp', + 'ɵdir', + 'ɵfac', + 'ɵinj', + 'ɵmod', + 'ɵpipe', + 'ɵprov', + 'INJECTOR_KEY', +]); + +/** + * A set of Angular metadata decorator functions that can be elided. + */ +const angularMetadataFunctions = new Set([ + 'ɵsetClassMetadata', + 'ɵsetClassMetadataAsync', + 'ɵsetClassDebugInfo', +]); + +/** + * A map of static properties and their matcher predicate functions to check if they + * can be safely elided from class declarations. + */ +const angularStaticsToElide: Record boolean> = { + 'ctorParameters'(node) { + return node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression'; + }, + 'decorators'(node) { + return node.type === 'ArrayExpression'; + }, + 'propDecorators'(node) { + return node.type === 'ObjectExpression'; + }, +}; + +/** + * Determines whether an AST node is considered safe for pure evaluation (side-effect free). + * + * @param node The AST node to check. + * @returns True if the node is side-effect free; otherwise, false. + */ +function isPure(node: Node): boolean { + switch (node.type) { + case 'Identifier': + case 'Literal': + return true; + case 'BinaryExpression': + case 'LogicalExpression': + return isPure(node.left) && isPure(node.right); + case 'UnaryExpression': + return isPure(node.argument); + case 'MemberExpression': + return isPure(node.object) && (!node.computed || isPure(node.property)); + case 'ObjectExpression': + return node.properties.every((p) => p.type === 'Property' && isPure(p.value)); + case 'ArrayExpression': + return node.elements.every((e) => !e || isPure(e)); + case 'ParenthesizedExpression': + return isPure(node.expression); + default: + return false; + } +} + +/** + * Recursively unwraps any ParenthesizedExpression wrapper nodes to get the inner expression. + * + * @param node The potentially parenthesized AST node. + * @returns The inner non-parenthesized AST node. + */ +function unwrapParentheses(node: Node): Node { + while (node && node.type === 'ParenthesizedExpression') { + node = node.expression; + } + + return node; +} + +/** + * Determines whether a class static property assignment is safe to be wrapped in a pure IIFE. + * + * @param propertyName The name of the property. + * @param assignmentValue The AST node representing the value assigned. + * @param code The source code of the file. + * @returns True if the property can be wrapped safely; otherwise, false. + */ +function canWrapProperty(propertyName: string, assignmentValue: Node, code: string): boolean { + if (angularStaticsToWrap.has(propertyName)) { + return true; + } + + const prefix = code.substring(Math.max(0, assignmentValue.start - 100), assignmentValue.start); + if (/(\/\*[\s\S]*?(?:@__PURE__|#__PURE__|@pureOrBreakMyCode)[\s\S]*?\*\/)\s*$/.test(prefix)) { + return true; + } + + return isPure(assignmentValue); +} + +/** + * Analyzes static properties inside a class body to determine if the class has any + * side-effecting static property initializers. + * + * @param classNode The Class AST node. + * @param code The source code of the file. + * @returns True if the class static properties are pure and can be wrapped; otherwise, false. + */ +function analyzeClassStaticProperties(classNode: Node, code: string): boolean { + let shouldWrap = false; + const body = (classNode as Class).body.body; + for (const element of body) { + if (element.type === 'PropertyDefinition') { + if (!element.static) { + continue; + } + + const key = element.key; + const value = element.value; + if (key.type === 'Identifier' && (!value || canWrapProperty(key.name, value, code))) { + shouldWrap = true; + } else { + shouldWrap = false; + break; + } + } else if (element.type === 'StaticBlock') { + const blockBody = element.body; + if (blockBody.length === 0) { + continue; + } + if (blockBody.length > 1) { + shouldWrap = false; + break; + } + const expressionStatement = blockBody[0]; + if (expressionStatement && expressionStatement.type === 'ExpressionStatement') { + const assignment = expressionStatement.expression; + if ( + assignment && + assignment.type === 'AssignmentExpression' && + assignment.left.type === 'MemberExpression' + ) { + const left = assignment.left; + if (left.object.type === 'ThisExpression' && left.property.type === 'Identifier') { + if (canWrapProperty(left.property.name, assignment.right, code)) { + shouldWrap = true; + continue; + } + } + } + } + shouldWrap = false; + break; + } + } + + return shouldWrap; +} + +/** + * Executes a single-pass optimized transformation using oxc-parser and magic-string. + * Performs typescript enum wrapping, static class members elision/wrapping, angular metadata elision, + * and top-level pure function annotations. + * + * @param filename The absolute path of the file being transformed. + * @param code The string source content of the file. + * @param options Configuration options specifying which optimization steps to run. + * @returns The transformed code string and an optional source map. + */ +// eslint-disable-next-line max-lines-per-function +export function transform(filename: string, code: string, options: OxcTransformOptions) { + const { program } = parseSync(filename, code, { range: true }); + const source = new MagicString(code); + + const shouldLink = options.link && needsLinking(filename, code); + const linker = shouldLink ? new OxcLinker(filename, code, options.jit) : undefined; + + const advancedOptimizations = options.advancedOptimizations ?? true; + const sideEffectFree = options.sideEffects === false; + const topLevelSafeMode = options.topLevelSafeMode ?? false; + const wrapDecorators = sideEffectFree; + const pureAnnotate = options.pureAnnotate ?? true; + + /** + * Scans backwards from the specified start index to check if a pure comment (e.g. `/*@__PURE__*\/`) + * already precedes the node. + * + * @param start The index where the node starts. + * @returns True if a pure comment is already present; otherwise, false. + */ + function hasPureComment(start: number): boolean { + let i = start - 1; + while (i >= 0 && /\s/.test(code[i])) { + i--; + } + if (i < 1 || code[i] !== '/' || code[i - 1] !== '*') { + return false; + } + const commentEnd = i + 1; + const commentStart = code.lastIndexOf('/*', commentEnd); + if (commentStart === -1) { + return false; + } + const commentContent = code.substring(commentStart + 2, commentEnd - 2); + + return commentContent.includes('@__PURE__') || commentContent.includes('#__PURE__'); + } + + const editedRanges: { start: number; end: number }[] = []; + + /** + * Records a range in the source code that has been modified, preventing subsequent nested mutations. + * + * @param start The start index of the modified range. + * @param end The end index of the modified range. + */ + function markEdited(start: number, end: number) { + editedRanges.push({ start, end }); + } + + /** + * Checks if the specified range falls inside an already modified section of code. + * + * @param start The start index of the range. + * @param end The end index of the range. + * @returns True if the range is already edited; otherwise, false. + */ + function isAlreadyEdited(start: number, end: number): boolean { + return editedRanges.some((r) => start >= r.start && end <= r.end); + } + + /** + * Scans and rewrites TypeScript emitted enum declarations in the statement block. + * Wraps enum statements inside a pure IIFE assignable directly to the enum variable. + * + * @param body The array of statement AST nodes to process. + */ + function adjustTypeScriptEnumsInStatements(body: Node[]) { + for (let i = 0; i < body.length - 1; i++) { + const statement = body[i]; + let declStatement = statement; + if ( + statement.type === 'ExportNamedDeclaration' && + statement.declaration?.type === 'VariableDeclaration' + ) { + declStatement = statement.declaration; + } + + if ( + declStatement.type !== 'VariableDeclaration' || + declStatement.kind !== 'var' || + declStatement.declarations.length !== 1 + ) { + continue; + } + const decl = declStatement.declarations[0]; + if (decl.init || decl.id.type !== 'Identifier') { + continue; + } + + const nextStatement = body[i + 1]; + if (nextStatement.type !== 'ExpressionStatement') { + continue; + } + + const nextExpr = unwrapParentheses(nextStatement.expression); + if (nextExpr.type !== 'CallExpression' || nextExpr.arguments.length !== 1) { + continue; + } + + const arg = unwrapParentheses(nextExpr.arguments[0]); + if (arg.type !== 'LogicalExpression' || arg.operator !== '||') { + continue; + } + + const argLeft = unwrapParentheses(arg.left); + if (argLeft.type !== 'Identifier' || argLeft.name !== decl.id.name) { + continue; + } + + const rightCallArgument = unwrapParentheses(arg.right); + if (rightCallArgument.type !== 'AssignmentExpression') { + continue; + } + + const callee = unwrapParentheses(nextExpr.callee); + if ( + callee.type !== 'FunctionExpression' || + callee.params.length !== 1 || + !callee.body || + callee.body.type !== 'BlockStatement' + ) { + continue; + } + + const param = callee.params[0]; + if (param.type !== 'Identifier') { + continue; + } + const paramName = (param as BindingIdentifier).name; + + // Check if all statements in body are pure assignments + let hasElements = false; + let allPure = true; + for (const enumStatement of callee.body.body) { + if (enumStatement.type !== 'ExpressionStatement') { + allPure = false; + break; + } + + const enumValueAssignment = unwrapParentheses(enumStatement.expression); + if ( + enumValueAssignment.type !== 'AssignmentExpression' || + !isPure(enumValueAssignment.right) + ) { + allPure = false; + break; + } + + hasElements = true; + } + + if (!allPure || !hasElements) { + continue; + } + + // 1. Remove leading/trailing characters/parentheses of the expression statement + source.remove(nextStatement.start, nextExpr.start); + source.remove(nextExpr.end, nextStatement.end); + markEdited(nextStatement.start, nextStatement.end); + + // 2. Add return statement inside IIFE body + source.appendRight(callee.body.end - 1, `; return ${paramName};`); + + // 3. Remove `Name = ` assignment in arguments if it's a simple identifier + if (rightCallArgument.left.type === 'Identifier') { + let replacement = code.substring( + rightCallArgument.right.start, + rightCallArgument.right.end, + ); + if (unwrapParentheses(rightCallArgument.right).type === 'AssignmentExpression') { + replacement = `(${replacement})`; + } + source.overwrite(arg.right.start, arg.right.end, replacement); + markEdited(arg.right.start, arg.right.end); + } + + // 4. Move IIFE to the var initializer + source.move(nextExpr.start, nextExpr.end, decl.id.end); + source.appendLeft(decl.id.end, ' = /*#__PURE__*/ '); + } + } + + /** + * Scans and rewrites static class member initializers in the statement block. + * Groups externalized class static assignments into pure wrappers or elides them when safe. + * + * @param body The array of statement AST nodes to process. + */ + function adjustStaticMembersInStatements(body: Node[]) { + for (let i = 0; i < body.length; i++) { + const statement = body[i]; + let classNode: Node | null = null; + let isExportDefault = false; + let isExportNamed = false; + let isVariableClass = false; + let classIdName = ''; + if (statement.type === 'ClassDeclaration') { + classNode = statement; + classIdName = classNode.id?.name || ''; + } else if ( + statement.type === 'ExportNamedDeclaration' && + statement.declaration?.type === 'ClassDeclaration' + ) { + classNode = statement.declaration; + classIdName = classNode.id?.name || ''; + isExportNamed = true; + } else if ( + statement.type === 'ExportDefaultDeclaration' && + statement.declaration?.type === 'ClassDeclaration' + ) { + classNode = statement.declaration; + classIdName = classNode.id?.name || ''; + isExportDefault = true; + } else if (statement.type === 'VariableDeclaration' && statement.declarations.length === 1) { + const decl = statement.declarations[0]; + if (decl.init && decl.init.type === 'ClassExpression' && decl.id.type === 'Identifier') { + classNode = decl.init; + classIdName = decl.id.name; + isVariableClass = true; + } + } + + if (!classNode || !classIdName) { + continue; + } + + const wrapStatementPaths: { statement: Node; type: 'wrap' | 'decorate' | 'elide' }[] = []; + let hasPotentialSideEffects = false; + + for (let j = i + 1; j < body.length; j++) { + const nextStatement = body[j]; + if (nextStatement.type !== 'ExpressionStatement') { + break; + } + + const nextExpression = nextStatement.expression; + + // Case 1: __decorate(...) + if (nextExpression.type === 'CallExpression') { + if ( + nextExpression.callee.type !== 'Identifier' || + nextExpression.callee.name !== '__decorate' + ) { + break; + } + + if (wrapDecorators) { + wrapStatementPaths.push({ statement: nextStatement, type: 'decorate' }); + } else { + hasPotentialSideEffects = true; + } + continue; + } + + // Case 2: AssignmentExpression + if (nextExpression.type !== 'AssignmentExpression') { + break; + } + + const left = nextExpression.left; + + if (left.type === 'Identifier') { + if ( + left.name !== classIdName || + nextExpression.right.type !== 'CallExpression' || + nextExpression.right.callee.type !== 'Identifier' || + nextExpression.right.callee.name !== '__decorate' + ) { + break; + } + + if (wrapDecorators) { + wrapStatementPaths.push({ statement: nextStatement, type: 'decorate' }); + } else { + hasPotentialSideEffects = true; + } + continue; + } + + if ( + left.type !== 'MemberExpression' || + left.object.type !== 'Identifier' || + left.object.name !== classIdName || + left.property.type !== 'Identifier' + ) { + break; + } + + const propertyName = left.property.name; + const assignmentValue = nextExpression.right; + + if (angularStaticsToElide[propertyName]?.(assignmentValue)) { + wrapStatementPaths.push({ statement: nextStatement, type: 'elide' }); + } else if (canWrapProperty(propertyName, assignmentValue, code)) { + wrapStatementPaths.push({ statement: nextStatement, type: 'wrap' }); + } else { + hasPotentialSideEffects = true; + } + } + + // Check class body static properties + const shouldWrapClassStaticProperties = analyzeClassStaticProperties(classNode, code); + + // Perform elisions immediately + for (const item of wrapStatementPaths) { + if (item.type === 'elide') { + source.remove(item.statement.start, item.statement.end); + markEdited(item.statement.start, item.statement.end); + } + } + + const activeWrapPaths = wrapStatementPaths.filter( + (p) => p.type === 'wrap' || p.type === 'decorate', + ); + + if ( + !hasPotentialSideEffects && + (activeWrapPaths.length > 0 || shouldWrapClassStaticProperties) + ) { + const lastStatement = + activeWrapPaths.length > 0 + ? activeWrapPaths[activeWrapPaths.length - 1].statement + : classNode; + + if (isExportDefault) { + // 1. Remove `export default ` + source.overwrite(statement.start, classNode.start, ''); + // 2. Wrap in IIFE + source.appendRight(classNode.start, `let ${classIdName} = /*#__PURE__*/ (() => {\n`); + source.appendLeft( + lastStatement.end, + `\nreturn ${classIdName};\n})();\nexport { ${classIdName} as default };`, + ); + } else if (isExportNamed) { + // 1. Export is kept, turn `class` into `let ClassName = IIFE` + source.appendRight(classNode.start, `let ${classIdName} = /*#__PURE__*/ (() => {\n`); + source.appendLeft(lastStatement.end, `\nreturn ${classIdName};\n})();`); + } else if (isVariableClass) { + // 2. Wrap class inside init: `(() => { let ClassName = class ClassName {}; return ClassName; })()` + source.appendRight(classNode.start, `/*#__PURE__*/ (() => {\nlet ${classIdName} = `); + const terminator = activeWrapPaths.length === 0 ? ';' : ''; + const iifeClosing = activeWrapPaths.length === 0 ? '})()' : '})();'; + source.appendLeft( + lastStatement.end, + `${terminator}\nreturn ${classIdName};\n${iifeClosing}`, + ); + } else { + // Standard ClassDeclaration + source.appendRight(classNode.start, `let ${classIdName} = /*#__PURE__*/ (() => {\n`); + source.appendLeft(lastStatement.end, `\nreturn ${classIdName};\n})();`); + } + + markEdited(statement.start, lastStatement.end); + + // Fast-forward outer loop index to skip the statements we wrapped + i += wrapStatementPaths.length; + } else if (isExportDefault && !hasPotentialSideEffects) { + // Splitting default export even when not wrapped + source.overwrite(statement.start, classNode.start, ''); + source.appendLeft(classNode.end, `\nexport { ${classIdName} as default };`); + markEdited(statement.start, classNode.end); + } + } + } + + traversePostOrder(program, (node, { functionDepth, classDepth, parentFunc }) => { + switch (node.type) { + case 'Program': + case 'BlockStatement': + if (advancedOptimizations) { + adjustTypeScriptEnumsInStatements(node.body); + adjustStaticMembersInStatements(node.body); + } + break; + + case 'CallExpression': { + if (isAlreadyEdited(node.start, node.end)) { + return; + } + + if (linker) { + const linkedCode = linker.linkCallExpression(node); + if (linkedCode !== undefined) { + source.overwrite(node.start, node.end, linkedCode); + markEdited(node.start, node.end); + + return; + } + } + + if (!advancedOptimizations) { + return; + } + + // 1. Elide Angular Metadata check + let calleeName: string | undefined; + if (node.callee.type === 'Identifier') { + calleeName = node.callee.name; + } else if ( + node.callee.type === 'MemberExpression' && + node.callee.property.type === 'Identifier' + ) { + calleeName = node.callee.property.name; + } + + if (calleeName && angularMetadataFunctions.has(calleeName)) { + if ( + parentFunc && + (parentFunc.type === 'FunctionExpression' || + parentFunc.type === 'ArrowFunctionExpression') + ) { + source.overwrite(node.start, node.end, 'void 0'); + markEdited(node.start, node.end); + + return; + } + } + + // 2. Mark Top-Level Pure Functions check + if (!pureAnnotate || functionDepth > 0 || classDepth > 0 || topLevelSafeMode) { + return; + } + + const callee = unwrapParentheses(node.callee); + if ( + (callee.type === 'FunctionExpression' || callee.type === 'ArrowFunctionExpression') && + node.arguments.length !== 0 + ) { + return; + } + + if ( + callee.type === 'Identifier' && + (isTslibHelperName(callee.name) || isBabelHelperName(callee.name)) + ) { + return; + } + + if (!hasPureComment(node.start)) { + source.appendLeft(node.start, '/*#__PURE__*/ '); + } + break; + } + + case 'NewExpression': { + if ( + !advancedOptimizations || + !pureAnnotate || + functionDepth > 0 || + classDepth > 0 || + isAlreadyEdited(node.start, node.end) + ) { + return; + } + + if (!topLevelSafeMode) { + if (!hasPureComment(node.start)) { + source.appendLeft(node.start, '/*#__PURE__*/ '); + } + + return; + } + + const newCallee = node.callee; + if (newCallee.type === 'Identifier' && sideEffectFreeConstructors.has(newCallee.name)) { + if (!hasPureComment(node.start)) { + source.appendLeft(node.start, '/*#__PURE__*/ '); + } + } + break; + } + } + }); + + let map: DecodedSourceMap | undefined; + if (options.sourcemap) { + const rawMap = source.generateDecodedMap({ hires: 'boundary', source: filename }); + map = { ...rawMap, version: 3 }; + } + + return { + code: source.toString(), + map, + }; +} diff --git a/packages/angular/build/src/tools/oxc/oxc-transform_spec.ts b/packages/angular/build/src/tools/oxc/oxc-transform_spec.ts new file mode 100644 index 000000000000..d1b02f6a1bdc --- /dev/null +++ b/packages/angular/build/src/tools/oxc/oxc-transform_spec.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { transform } from './oxc-transform'; + +describe('oxc-transform', () => { + describe('sourcemaps', () => { + it('should generate a decoded sourcemap when sourcemap option is enabled', () => { + const input = 'var result = new SomeClass();'; + const result = transform('test.js', input, { sourcemap: true }); + + expect(result.map).toBeDefined(); + expect(result.map?.version).toBe(3); + expect(result.map?.sources).toContain('test.js'); + expect(result.map?.mappings.length).toBeGreaterThan(0); + }); + }); + + describe('linking and unified passes', () => { + const componentInput = ` + import * as i0 from "@angular/core"; + export class MyComponent {} + MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ + minVersion: "12.0.0", + version: "14.0.0", + ngImport: i0, + type: MyComponent, + isStandalone: true, + selector: "my-cmp", + template: "Hello" + }); + `; + + it('should link partial component declarations when link option is enabled', () => { + const result = transform('test.js', componentInput, { link: true }); + expect(result.code).toContain('i0.ɵɵdefineComponent'); + expect(result.code).not.toContain('i0.ɵɵngDeclareComponent'); + }); + + it('should not link partial component declarations when link option is disabled', () => { + const result = transform('test.js', componentInput, { link: false }); + expect(result.code).not.toContain('i0.ɵɵdefineComponent'); + expect(result.code).toContain('i0.ɵɵngDeclareComponent'); + }); + + it('should perform linking and advanced optimizations simultaneously in a single pass', () => { + const input = ` + import * as i0 from "@angular/core"; + export class MyComponent { + static create() { + return new MyComponent(); + } + } + MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ + minVersion: "12.0.0", + version: "14.0.0", + ngImport: i0, + type: MyComponent, + isStandalone: true, + selector: "my-cmp", + template: "Hello" + }); + `; + + const result = transform('test.js', input, { + link: true, + advancedOptimizations: true, + topLevelSafeMode: true, + }); + expect(result.code).toContain('i0.ɵɵdefineComponent'); + expect(result.code).not.toContain('i0.ɵɵngDeclareComponent'); + expect(result.code).toContain('let MyComponent = /*#__PURE__*/ (() => {'); + }); + }); +}); diff --git a/packages/angular/build/src/tools/oxc/pure-toplevel-functions_oxc_spec.ts b/packages/angular/build/src/tools/oxc/pure-toplevel-functions_oxc_spec.ts new file mode 100644 index 000000000000..0b91cc98aa45 --- /dev/null +++ b/packages/angular/build/src/tools/oxc/pure-toplevel-functions_oxc_spec.ts @@ -0,0 +1,202 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { transform } from './oxc-transform'; + +function cleanCode(code: string): string { + return code + .replace(/\s+/g, '') + .replace(/"/g, "'") + .replace(/;}/g, '}') + .replace(/,([}\])])/g, '$1'); +} + +function testCase({ + input, + expected, + options, +}: { + input: string; + expected: string; + options?: { topLevelSafeMode: boolean }; +}): jasmine.ImplementationCallback { + return async () => { + const result = transform('test.js', input, { + sourcemap: false, + topLevelSafeMode: options?.topLevelSafeMode, + }); + if (!result?.code) { + fail('Expected oxc-transform to return a transform result.'); + } else { + const actualClean = cleanCode(result.code); + const expectedClean = cleanCode(expected); + expect(actualClean).toEqual(expectedClean); + } + }; +} + +function testCaseNoChange(input: string): jasmine.ImplementationCallback { + return testCase({ input, expected: input }); +} + +describe('pure-toplevel-functions oxc-transform implementation', () => { + it( + 'annotates top-level new expressions', + testCase({ + input: 'var result = new SomeClass();', + expected: 'var result = /*#__PURE__*/ new SomeClass();', + }), + ); + + it( + 'annotates top-level function calls', + testCase({ + input: 'var result = someCall();', + expected: 'var result = /*#__PURE__*/ someCall();', + }), + ); + + it( + 'annotates top-level IIFE assignments with no arguments', + testCase({ + input: 'var SomeClass = (function () { function SomeClass() { } return SomeClass; })();', + expected: + 'var SomeClass = /*#__PURE__*/(function () { function SomeClass() { } return SomeClass; })();', + }), + ); + + it( + 'annotates top-level arrow-function-based IIFE assignments with no arguments', + testCase({ + input: 'var SomeClass = (() => { function SomeClass() { } return SomeClass; })();', + expected: + 'var SomeClass = /*#__PURE__*/(() => { function SomeClass() { } return SomeClass; })();', + }), + ); + + it( + 'does not annotate top-level IIFE assignments with arguments', + testCaseNoChange( + 'var SomeClass = (function () { function SomeClass() { } return SomeClass; })(abc);', + ), + ); + + it( + 'does not annotate top-level arrow-function-based IIFE assignments with arguments', + testCaseNoChange( + 'var SomeClass = (() => { function SomeClass() { } return SomeClass; })(abc);', + ), + ); + + it( + 'does not annotate call expressions inside function declarations', + testCaseNoChange('function funcDecl() { const result = someFunction(); }'), + ); + + it( + 'does not annotate call expressions inside function expressions', + testCaseNoChange('const foo = function funcDecl() { const result = someFunction(); }'), + ); + + it( + 'does not annotate call expressions inside arrow functions', + testCaseNoChange('const foo = () => { const result = someFunction(); }'), + ); + + it( + 'does not annotate new expressions inside function declarations', + testCaseNoChange('function funcDecl() { const result = new SomeClass(); }'), + ); + + it( + 'does not annotate new expressions inside function expressions', + testCaseNoChange('const foo = function funcDecl() { const result = new SomeClass(); }'), + ); + + it( + 'does not annotate new expressions inside arrow functions', + testCaseNoChange('const foo = () => { const result = new SomeClass(); }'), + ); + + it( + 'does not annotate TypeScript helper functions (tslib)', + testCaseNoChange(` + class LanguageState {} + __decorate([ + __metadata("design:type", Function), + __metadata("design:paramtypes", [Object]), + __metadata("design:returntype", void 0) + ], LanguageState.prototype, "checkLanguage", null); + `), + ); + + it( + 'does not annotate _defineProperty function', + testCaseNoChange(` + class LanguageState {} + _defineProperty( + LanguageState, + 'property', + 'value' + ); + `), + ); + + it( + 'does not annotate object literal methods', + testCaseNoChange(` + const literal = { + method() { + var newClazz = new Clazz(); + } + }; + `), + ); + + it( + 'annotates helper functions with non-numeric suffixes', + testCase({ + input: 'var result = __decorate$foo();', + expected: 'var result = /*#__PURE__*/ __decorate$foo();', + }), + ); + + it( + 'does not annotate helper functions with numeric suffixes', + testCaseNoChange('var result = __decorate$1();'), + ); + + describe('topLevelSafeMode: true', () => { + it( + 'annotates top-level `new InjectionToken` expressions', + testCase({ + input: `const result = new InjectionToken('abc');`, + expected: `const result = /*#__PURE__*/ new InjectionToken('abc');`, + options: { topLevelSafeMode: true }, + }), + ); + + it( + 'does not annotate other top-level `new` expressions', + testCase({ + input: 'const result = new SomeClass();', + expected: 'const result = new SomeClass();', + options: { topLevelSafeMode: true }, + }), + ); + + it( + 'does not annotate top-level function calls', + testCase({ + input: 'const result = someCall();', + expected: 'const result = someCall();', + options: { topLevelSafeMode: true }, + }), + ); + }); +}); diff --git a/packages/angular/build/src/tools/oxc/traversal.ts b/packages/angular/build/src/tools/oxc/traversal.ts new file mode 100644 index 000000000000..f25f4bc78664 --- /dev/null +++ b/packages/angular/build/src/tools/oxc/traversal.ts @@ -0,0 +1,354 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Node } from '@oxc-project/types'; +import { visitorKeys } from 'oxc-parser'; + +/** + * Contextual scope information provided to AST visitor callbacks during traversal. + */ +export interface TraversalContext { + /** + * The current function nesting depth. Top-level code has depth 0. + */ + functionDepth: number; + + /** + * The current class nesting depth. Top-level code has depth 0. + */ + classDepth: number; + + /** + * The immediate enclosing function AST node, if currently inside a function. + */ + parentFunc?: Node; +} + +/** + * A set of TypeScript AST node types that contain executable code or runtime declarations. + */ +const executableTypeScriptNodes = new Set([ + 'TSEnumDeclaration', + 'TSEnumBody', + 'TSEnumMember', + 'TSModuleDeclaration', + 'TSModuleBlock', + 'TSParameterProperty', + 'TSImportEqualsDeclaration', + 'TSExternalModuleReference', +]); + +/** + * Determines whether the given AST node type represents a function declaration or expression. + * + * @param type The AST node type to check. + * @returns True if the type is a function; otherwise, false. + */ +function isFunction(type: Node['type']): boolean { + return ( + type === 'FunctionDeclaration' || + type === 'FunctionExpression' || + type === 'ArrowFunctionExpression' + ); +} + +/** + * Determines whether the given AST node type represents a class declaration or expression. + * + * @param type The AST node type to check. + * @returns True if the type is a class; otherwise, false. + */ +function isClass(type: Node['type']): boolean { + return type === 'ClassDeclaration' || type === 'ClassExpression'; +} + +/** + * Determines whether the given AST node type represents a non-executable TypeScript type-only node. + * Executable TypeScript nodes (e.g. enums, namespaces, parameter properties) return false. + * + * @param type The AST node type to check. + * @returns True if the type is a TypeScript type construct that can be safely pruned; otherwise, false. + */ +function isTypeOnlyTypeScriptNode(type: Node['type']): boolean { + return type.startsWith('TS') && !executableTypeScriptNodes.has(type); +} + +/** + * Pushes an array of AST nodes onto the traversal stack in reverse order. + * + * @param nodes The array of child AST nodes to push. + * @param stack The traversal stack. + */ +function pushNodesReverse( + nodes: readonly (Node | null | undefined)[] | undefined, + stack: (Node | null)[], +): void { + if (nodes) { + for (let i = nodes.length - 1; i >= 0; i--) { + const item = nodes[i]; + if (item) { + stack.push(item); + } + } + } +} + +/** + * Pushes child AST nodes of the specified node onto the traversal stack in reverse order + * so they are visited in source order. + * + * Explicit cases are provided for the most common AST node types (~95%+ of AST nodes) to: + * 1. Fast-path leaf nodes and frequent expressions/statements by avoiding dynamic `visitorKeys` + * dictionary lookups, string indexing, and dynamic array iteration. + * 2. Prune non-executable TypeScript type subtrees (e.g. `typeAnnotation`, `typeParameters`, + * `returnType`, `implements`) so traversal only processes executable JavaScript expressions. + * + * All other less frequent node types fall back to the `visitorKeys` lookup in `default:`. + * + * @param node The parent AST node whose children to push. + * @param stack The traversal stack. + */ +function pushChildNodes(node: Node, stack: (Node | null)[]): void { + switch (node.type) { + // High-frequency leaf nodes (~50% of AST) + case 'Identifier': + case 'PrivateIdentifier': + case 'Literal': + case 'ThisExpression': + break; + + case 'Program': + case 'BlockStatement': + case 'ClassBody': + case 'StaticBlock': + pushNodesReverse(node.body, stack); + break; + + case 'ExpressionStatement': + case 'ParenthesizedExpression': + case 'ChainExpression': + case 'TSNonNullExpression': + case 'TSAsExpression': + case 'TSTypeAssertion': + case 'TSSatisfiesExpression': + case 'TSInstantiationExpression': + case 'TSExportAssignment': + if (node.expression) { + stack.push(node.expression); + } + break; + + case 'CallExpression': + case 'NewExpression': + pushNodesReverse(node.arguments, stack); + if (node.callee) { + stack.push(node.callee); + } + break; + + case 'MemberExpression': + if (node.property) { + stack.push(node.property); + } + if (node.object) { + stack.push(node.object); + } + break; + + case 'BinaryExpression': + case 'LogicalExpression': + case 'AssignmentExpression': + if (node.right) { + stack.push(node.right); + } + if (node.left) { + stack.push(node.left); + } + break; + + case 'UnaryExpression': + case 'UpdateExpression': + case 'AwaitExpression': + case 'YieldExpression': + case 'ReturnStatement': + case 'ThrowStatement': + if (node.argument) { + stack.push(node.argument); + } + break; + + case 'IfStatement': + case 'ConditionalExpression': + if (node.alternate) { + stack.push(node.alternate); + } + if (node.consequent) { + stack.push(node.consequent); + } + if (node.test) { + stack.push(node.test); + } + break; + + case 'VariableDeclaration': + pushNodesReverse(node.declarations, stack); + break; + + case 'VariableDeclarator': + if (node.init) { + stack.push(node.init); + } + if (node.id) { + stack.push(node.id); + } + break; + + case 'FunctionDeclaration': + case 'FunctionExpression': + case 'ArrowFunctionExpression': + if (node.body) { + stack.push(node.body); + } + pushNodesReverse(node.params, stack); + if (node.id) { + stack.push(node.id); + } + break; + + case 'ClassDeclaration': + case 'ClassExpression': + if (node.body) { + stack.push(node.body); + } + if (node.superClass) { + stack.push(node.superClass); + } + if (node.id) { + stack.push(node.id); + } + pushNodesReverse(node.decorators, stack); + break; + + case 'Property': + case 'PropertyDefinition': + case 'MethodDefinition': + if (node.value) { + stack.push(node.value); + } + if (node.key) { + stack.push(node.key); + } + if (node.type !== 'Property') { + pushNodesReverse(node.decorators, stack); + } + break; + + case 'ObjectExpression': + pushNodesReverse(node.properties, stack); + break; + + case 'ArrayExpression': + pushNodesReverse(node.elements, stack); + break; + + case 'TemplateLiteral': + pushNodesReverse(node.expressions, stack); + break; + + case 'TaggedTemplateExpression': + if (node.quasi) { + stack.push(node.quasi); + } + if (node.tag) { + stack.push(node.tag); + } + break; + + default: { + // Prune non-executable TypeScript type-only AST nodes (e.g. TSTypeAnnotation, TSTypeReference, etc.) + if (isTypeOnlyTypeScriptNode(node.type)) { + break; + } + + const keys = visitorKeys[node.type]; + if (!keys) { + break; + } + + for (let i = keys.length - 1; i >= 0; i--) { + const child = ( + node as unknown as Record + )[keys[i]]; + if (!child) { + continue; + } + + if (Array.isArray(child)) { + pushNodesReverse(child, stack); + } else { + stack.push(child); + } + } + break; + } + } +} + +/** + * Traverses ESTree AST nodes in post-order (bottom-up) without recursion. + * + * @param root The root AST node to traverse. + * @param visit Callback invoked on each AST node in post-order. + */ +export function traversePostOrder( + root: Node, + visit: (node: Node, context: TraversalContext) => void, +): void { + const stack: (Node | null)[] = [root]; + let functionDepth = 0; + let classDepth = 0; + const functionStack: Node[] = []; + + while (stack.length > 0) { + const current = stack.pop(); + if (current === undefined) { + break; + } + if (current === null) { + const node = stack.pop(); + if (!node) { + continue; + } + const type = node.type; + if (isFunction(type)) { + functionDepth--; + functionStack.pop(); + } else if (isClass(type)) { + classDepth--; + } + visit(node, { + functionDepth, + classDepth, + parentFunc: functionStack[functionStack.length - 1], + }); + continue; + } + + stack.push(current); + stack.push(null); + const type = current.type; + if (isFunction(type)) { + functionDepth++; + functionStack.push(current); + } else if (isClass(type)) { + classDepth++; + } + + pushChildNodes(current, stack); + } +} diff --git a/packages/angular/build/src/tools/oxc/types.d.ts b/packages/angular/build/src/tools/oxc/types.d.ts new file mode 100644 index 000000000000..aa1f40580491 --- /dev/null +++ b/packages/angular/build/src/tools/oxc/types.d.ts @@ -0,0 +1,20 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +declare module 'istanbul-lib-instrument' { + export interface Instrumenter { + instrumentSync(code: string, filename: string, inputSourceMap?: object): string; + lastSourceMap(): object | undefined; + } + + export function createInstrumenter(options?: { + produceSourceMap?: boolean; + esModules?: boolean; + coverageVariable?: string; + }): Instrumenter; +} diff --git a/packages/angular/build/src/tools/sass/lexer.ts b/packages/angular/build/src/tools/sass/lexer.ts new file mode 100644 index 000000000000..0fc4f7d1e543 --- /dev/null +++ b/packages/angular/build/src/tools/sass/lexer.ts @@ -0,0 +1,182 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +// TODO: Combine everything into a single pass lexer + +/** + * Determines if a unicode code point is a CSS whitespace character. + * @param code The unicode code point to test. + * @returns true, if the code point is CSS whitespace; false, otherwise. + */ +function isWhitespace(code: number): boolean { + // Based on https://www.w3.org/TR/css-syntax-3/#whitespace + switch (code) { + case 0x0009: // tab + case 0x0020: // space + case 0x000a: // line feed + case 0x000c: // form feed + case 0x000d: // carriage return + return true; + default: + return false; + } +} + +/** + * Scans a CSS or Sass file and locates all valid url function values as defined by the + * syntax specification. + * @param contents A string containing a CSS or Sass file to scan. + * @returns An iterable that yields each CSS url function value found. + */ +export function* findUrls( + contents: string, +): Iterable<{ start: number; end: number; value: string }> { + let pos = 0; + let width = 1; + let current = -1; + const next = () => { + pos += width; + current = contents.codePointAt(pos) ?? -1; + width = current > 0xffff ? 2 : 1; + + return current; + }; + + // Based on https://www.w3.org/TR/css-syntax-3/#consume-ident-like-token + while ((pos = contents.indexOf('url(', pos)) !== -1) { + width = 1; + + // Ensure whitespace, comma, or colon before `url(` + if (pos > 0) { + pos -= 2; + next(); + if (!isWhitespace(current) && current !== 0x002c && current !== 0x003a) { + // Skip - not a url token + pos += 3; + continue; + } + pos += 1; + } + + // Set to position of the ( + pos += 3; + + // Consume all leading whitespace + while (isWhitespace(next())) { + /* empty */ + } + + // Initialize URL state + const url = { start: pos, end: -1, value: '' }; + let complete = false; + + // If " or ', then consume the value as a string + if (current === 0x0022 || current === 0x0027) { + const ending = current; + // Based on https://www.w3.org/TR/css-syntax-3/#consume-string-token + while (!complete) { + switch (next()) { + case -1: // EOF + return; + case 0x000a: // line feed + case 0x000c: // form feed + case 0x000d: // carriage return + // Invalid + complete = true; + break; + case 0x005c: // \ -- character escape + // If not EOF or newline, add the character after the escape + switch (next()) { + case -1: + return; + case 0x000a: // line feed + case 0x000c: // form feed + case 0x000d: // carriage return + // Skip when inside a string + break; + default: + // TODO: Handle hex escape codes + url.value += String.fromCodePoint(current); + break; + } + break; + case ending: + // Full string position should include the quotes for replacement + url.end = pos + 1; + complete = true; + yield url; + break; + default: + url.value += String.fromCodePoint(current); + break; + } + } + + next(); + continue; + } + + // Based on https://www.w3.org/TR/css-syntax-3/#consume-url-token + while (!complete) { + switch (current) { + case -1: // EOF + return; + case 0x0022: // " + case 0x0027: // ' + case 0x0028: // ( + // Invalid + complete = true; + break; + case 0x0029: // ) + // URL is valid and complete + url.end = pos; + complete = true; + break; + case 0x005c: // \ -- character escape + // If not EOF or newline, add the character after the escape + switch (next()) { + case -1: // EOF + return; + case 0x000a: // line feed + case 0x000c: // form feed + case 0x000d: // carriage return + // Invalid + complete = true; + break; + default: + // TODO: Handle hex escape codes + url.value += String.fromCodePoint(current); + break; + } + break; + default: + if (isWhitespace(current)) { + while (isWhitespace(next())) { + /* empty */ + } + // Unescaped whitespace is only valid before the closing ) + if (current === 0x0029) { + // URL is valid + url.end = pos; + } + complete = true; + } else { + // Add the character to the url value + url.value += String.fromCodePoint(current); + } + break; + } + next(); + } + + // An end position indicates a URL was found + if (url.end !== -1) { + yield url; + } + } +} diff --git a/packages/angular/build/src/tools/sass/lexer_spec.ts b/packages/angular/build/src/tools/sass/lexer_spec.ts new file mode 100644 index 000000000000..151077af4c7f --- /dev/null +++ b/packages/angular/build/src/tools/sass/lexer_spec.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { findUrls } from './lexer'; + +describe('Sass URL lexer', () => { + it('should find url immediately after a colon, comma, or whitespace', () => { + const input = `.c{property:url("first"),url("second"), url("third");}`; + + const result = [...findUrls(input)]; + + expect(result).toEqual([ + { start: 16, end: 23, value: 'first' }, + { start: 29, end: 37, value: 'second' }, + { start: 44, end: 51, value: 'third' }, + ]); + }); + + it('should find full url with string containing url function', () => { + const input = `.c{property:url("url('abc')");}`; + + const result = [...findUrls(input)]; + + expect(result).toEqual([{ start: 16, end: 28, value: `url('abc')` }]); + }); +}); diff --git a/packages/angular/build/src/tools/sass/rebasing-importer.ts b/packages/angular/build/src/tools/sass/rebasing-importer.ts new file mode 100644 index 000000000000..ccc5fa3c4664 --- /dev/null +++ b/packages/angular/build/src/tools/sass/rebasing-importer.ts @@ -0,0 +1,396 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { DecodedSourceMap } from '@ampproject/remapping'; +import { MagicString } from 'magic-string'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { basename, dirname, extname, join, relative } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import type { CanonicalizeContext, Importer, ImporterResult, Syntax } from 'sass-embedded'; +import { assertIsError } from '../../utils/error'; +import { toPosixPath } from '../../utils/path'; +import { findUrls } from './lexer'; + +/** + * A preprocessed cache entry for the files and directories within a previously searched + * directory when performing Sass import resolution. + */ +export interface DirectoryEntry { + files: Set; + directories: Set; +} + +/** + * A Sass Importer base class that provides the load logic to rebase all `url()` functions + * within a stylesheet. The rebasing will ensure that the URLs in the output of the Sass compiler + * reflect the final filesystem location of the output CSS file. + * + * This class provides the core of the rebasing functionality. To ensure that each file is processed + * by this importer's load implementation, the Sass compiler requires the importer's canonicalize + * function to return a non-null value with the resolved location of the requested stylesheet. + * Concrete implementations of this class must provide this canonicalize functionality for rebasing + * to be effective. + */ +abstract class UrlRebasingImporter implements Importer<'sync'> { + /** + * @param entryDirectory The directory of the entry stylesheet that was passed to the Sass compiler. + * @param rebaseSourceMaps When provided, rebased files will have an intermediate sourcemap added to the Map + * which can be used to generate a final sourcemap that contains original sources. + */ + constructor( + private entryDirectory: string, + private rebaseSourceMaps?: Map, + ) { + this.load = this.load.bind(this); + } + + abstract canonicalize(url: string, options: { fromImport: boolean }): URL | null; + + load(canonicalUrl: URL): ImporterResult | null { + const stylesheetPath = fileURLToPath(canonicalUrl); + const stylesheetDirectory = dirname(stylesheetPath); + let contents = readFileSync(stylesheetPath, 'utf-8'); + + // Rebase any URLs that are found + let updatedContents; + for (const { start, end, value } of findUrls(contents)) { + // Skip if value is empty or Webpack-specific prefix + if (value.length === 0 || value[0] === '~' || value[0] === '^') { + continue; + } + + // Skip if root-relative, absolute or protocol relative url + if (/^((?:\w+:)?\/\/|data:|chrome:|\/)/.test(value)) { + continue; + } + + // Skip if a fragment identifier but not a Sass interpolation + if (value[0] === '#' && value[1] !== '{') { + continue; + } + + // Skip if value is value contains a function call + if (/#\{.+\(.+\)\}/.test(value)) { + continue; + } + + // Sass variable usage either starts with a `$` or contains a namespace and a `.$` + const valueNormalized = + value[0] === '$' || /^\w[\w_-]*\.\$/.test(value) ? `#{${value}}` : value; + const rebasedPath = relative(this.entryDirectory, stylesheetDirectory); + + // Normalize path separators and escape characters + // https://developer.mozilla.org/en-US/docs/Web/CSS/url#syntax + const rebasedUrl = toPosixPath(rebasedPath).replace(/[()\s'"]/g, '\\$&'); + + updatedContents ??= new MagicString(contents); + // Always quote the URL to avoid potential downstream parsing problems + updatedContents.update(start, end, `"${rebasedUrl}||file:${valueNormalized}"`); + } + + if (updatedContents) { + contents = updatedContents.toString(); + if (this.rebaseSourceMaps) { + // Generate an intermediate source map for the rebasing changes + const map = updatedContents.generateDecodedMap({ + hires: 'boundary', + includeContent: true, + source: canonicalUrl.href, + }); + this.rebaseSourceMaps.set(canonicalUrl.href, { + ...map, + version: 3, + } satisfies DecodedSourceMap); + } + } + + let syntax: Syntax | undefined; + switch (extname(stylesheetPath).toLowerCase()) { + case '.css': + syntax = 'css'; + break; + case '.sass': + syntax = 'indented'; + break; + default: + syntax = 'scss'; + break; + } + + return { + contents, + syntax, + sourceMapUrl: canonicalUrl, + }; + } +} + +/** + * Provides the Sass importer logic to resolve relative stylesheet imports via both import and use rules + * and also rebase any `url()` function usage within those stylesheets. The rebasing will ensure that + * the URLs in the output of the Sass compiler reflect the final filesystem location of the output CSS file. + */ +export class RelativeUrlRebasingImporter extends UrlRebasingImporter { + constructor( + entryDirectory: string, + private directoryCache = new Map(), + rebaseSourceMaps?: Map, + ) { + super(entryDirectory, rebaseSourceMaps); + this.canonicalize = this.canonicalize.bind(this); + } + + canonicalize(url: string, options: { fromImport: boolean }): URL | null { + return this.resolveImport(url, options.fromImport, true); + } + + /** + * Attempts to resolve a provided URL to a stylesheet file using the Sass compiler's resolution algorithm. + * Based on https://github.com/sass/dart-sass/blob/44d6bb6ac72fe6b93f5bfec371a1fffb18e6b76d/lib/src/importer/utils.dart + * @param url The file protocol URL to resolve. + * @param fromImport If true, URL was from an import rule; otherwise from a use rule. + * @param checkDirectory If true, try checking for a directory with the base name containing an index file. + * @returns A full resolved URL of the stylesheet file or `null` if not found. + */ + private resolveImport(url: string, fromImport: boolean, checkDirectory: boolean): URL | null { + let stylesheetPath; + try { + stylesheetPath = fileURLToPath(url); + } catch { + // Only file protocol URLs are supported by this importer + return null; + } + + const directory = dirname(stylesheetPath); + const extension = extname(stylesheetPath); + const hasStyleExtension = + extension === '.scss' || extension === '.sass' || extension === '.css'; + // Remove the style extension if present to allow adding the `.import` suffix + const filename = basename(stylesheetPath, hasStyleExtension ? extension : undefined); + + const importPotentials = new Set(); + const defaultPotentials = new Set(); + + if (hasStyleExtension) { + if (fromImport) { + importPotentials.add(filename + '.import' + extension); + importPotentials.add('_' + filename + '.import' + extension); + } + defaultPotentials.add(filename + extension); + defaultPotentials.add('_' + filename + extension); + } else { + if (fromImport) { + importPotentials.add(filename + '.import.scss'); + importPotentials.add(filename + '.import.sass'); + importPotentials.add(filename + '.import.css'); + importPotentials.add('_' + filename + '.import.scss'); + importPotentials.add('_' + filename + '.import.sass'); + importPotentials.add('_' + filename + '.import.css'); + } + defaultPotentials.add(filename + '.scss'); + defaultPotentials.add(filename + '.sass'); + defaultPotentials.add(filename + '.css'); + defaultPotentials.add('_' + filename + '.scss'); + defaultPotentials.add('_' + filename + '.sass'); + defaultPotentials.add('_' + filename + '.css'); + } + + let foundDefaults; + let foundImports; + let hasPotentialIndex = false; + + let cachedEntries = this.directoryCache.get(directory); + if (cachedEntries) { + // If there is a preprocessed cache of the directory, perform an intersection of the potentials + // and the directory files. + const { files, directories } = cachedEntries; + foundDefaults = [...defaultPotentials.intersection(files)]; + foundImports = [...importPotentials.intersection(files)]; + hasPotentialIndex = checkDirectory && !hasStyleExtension && directories.has(filename); + } else { + // If no preprocessed cache exists, get the entries from the file system and, while searching, + // generate the cache for later requests. + let entries; + try { + entries = readdirSync(directory, { withFileTypes: true }); + } catch (error) { + assertIsError(error); + // If the containing directory does not exist return null to indicate it cannot be resolved + if (error.code === 'ENOENT') { + return null; + } + + throw new Error(`Error reading directory ["${directory}"] while resolving Sass import`, { + cause: error, + }); + } + + foundDefaults = []; + foundImports = []; + cachedEntries = { files: new Set(), directories: new Set() }; + for (const entry of entries) { + let isDirectory: boolean; + let isFile: boolean; + + if (entry.isSymbolicLink()) { + const stats = statSync(join(directory, entry.name)); + isDirectory = stats.isDirectory(); + isFile = stats.isFile(); + } else { + isDirectory = entry.isDirectory(); + isFile = entry.isFile(); + } + + if (isDirectory) { + cachedEntries.directories.add(entry.name); + + // Record if the name should be checked as a directory with an index file + if (checkDirectory && !hasStyleExtension && entry.name === filename) { + hasPotentialIndex = true; + } + } + + if (!isFile) { + continue; + } + + cachedEntries.files.add(entry.name); + + if (importPotentials.has(entry.name)) { + foundImports.push(entry.name); + } + + if (defaultPotentials.has(entry.name)) { + foundDefaults.push(entry.name); + } + } + + this.directoryCache.set(directory, cachedEntries); + } + + // `foundImports` will only contain elements if `options.fromImport` is true + const result = this.checkFound(foundImports) ?? this.checkFound(foundDefaults); + if (result !== null) { + return pathToFileURL(join(directory, result)); + } + + if (hasPotentialIndex) { + // Check for index files using filename as a directory + return this.resolveImport(url + '/index', fromImport, false); + } + + return null; + } + + /** + * Checks an array of potential stylesheet files to determine if there is a valid + * stylesheet file. More than one discovered file may indicate an error. + * @param found An array of discovered stylesheet files. + * @returns A fully resolved path for a stylesheet file or `null` if not found. + * @throws If there are ambiguous files discovered. + */ + private checkFound(found: string[]): string | null { + if (found.length === 0) { + // Not found + return null; + } + + // More than one found file may be an error + if (found.length > 1) { + // Presence of CSS files alongside a Sass file does not cause an error + const foundWithoutCss = found.filter((element) => extname(element) !== '.css'); + // If the length is zero then there are two or more css files + // If the length is more than one than there are two or more sass/scss files + if (foundWithoutCss.length !== 1) { + throw new Error('Ambiguous import detected.'); + } + + // Return the non-CSS file (sass/scss files have priority) + // https://github.com/sass/dart-sass/blob/44d6bb6ac72fe6b93f5bfec371a1fffb18e6b76d/lib/src/importer/utils.dart#L44-L47 + return foundWithoutCss[0]; + } + + return found[0]; + } +} + +/** + * Provides the Sass importer logic to resolve module (npm package) stylesheet imports asynchronously + * and also rebase any `url()` function usage within those stylesheets. + */ +export class AsyncModuleUrlRebasingImporter implements Importer<'async'> { + private relativeImporter: RelativeUrlRebasingImporter; + + constructor( + entryDirectory: string, + directoryCache: Map, + rebaseSourceMaps: Map | undefined, + private finder: ( + specifier: string, + options: CanonicalizeContext, + ) => Promise | URL | null, + ) { + this.relativeImporter = new RelativeUrlRebasingImporter( + entryDirectory, + directoryCache, + rebaseSourceMaps, + ); + this.canonicalize = this.canonicalize.bind(this); + this.load = this.load.bind(this); + } + + canonicalize(url: string, options: CanonicalizeContext): Promise | URL | null { + if (url.startsWith('file://')) { + return this.relativeImporter.canonicalize(url, options); + } + + // Use `Promise.resolve` instead of an `async` method so the returned promise is an + // instance of the current `globalThis.Promise` (e.g., `ZoneAwarePromise` when Zone.js + // is loaded during SSR). `sass-embedded` checks `result instanceof Promise`, which fails + // for native `async` function return values when `globalThis.Promise` has been replaced. + return Promise.resolve(this.finder(url, options)).then((result) => + result ? this.relativeImporter.canonicalize(result.href, options) : null, + ); + } + + load(canonicalUrl: URL): ImporterResult | null { + return this.relativeImporter.load(canonicalUrl); + } +} + +/** + * Provides the Sass importer logic to resolve load paths located stylesheet imports via both import and + * use rules and also rebase any `url()` function usage within those stylesheets. The rebasing will ensure that + * the URLs in the output of the Sass compiler reflect the final filesystem location of the output CSS file. + */ +export class LoadPathsUrlRebasingImporter extends RelativeUrlRebasingImporter { + constructor( + entryDirectory: string, + directoryCache: Map, + rebaseSourceMaps: Map | undefined, + private loadPaths: Iterable, + ) { + super(entryDirectory, directoryCache, rebaseSourceMaps); + } + + override canonicalize(url: string, options: { fromImport: boolean }): URL | null { + if (url.startsWith('file://')) { + return super.canonicalize(url, options); + } + + let result = null; + for (const loadPath of this.loadPaths) { + result = super.canonicalize(pathToFileURL(join(loadPath, url)).href, options); + if (result !== null) { + break; + } + } + + return result; + } +} diff --git a/packages/angular/build/src/tools/sass/sass-service.ts b/packages/angular/build/src/tools/sass/sass-service.ts new file mode 100644 index 000000000000..8ce65dd7e2f2 --- /dev/null +++ b/packages/angular/build/src/tools/sass/sass-service.ts @@ -0,0 +1,219 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import mergeSourceMaps, { type DecodedSourceMap, type RawSourceMap } from '@ampproject/remapping'; +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { + AsyncCompiler, + CanonicalizeContext, + CompileResult, + FileImporter, + Importer, + NodePackageImporter, + StringOptions, +} from 'sass-embedded'; +import { useSassEmbedded } from '../../utils/environment-options'; +import { + AsyncModuleUrlRebasingImporter, + DirectoryEntry, + LoadPathsUrlRebasingImporter, + RelativeUrlRebasingImporter, +} from './rebasing-importer'; + +type Importers = + | Importer<'sync'> + | Importer<'async'> + | FileImporter<'sync'> + | FileImporter<'async'> + | NodePackageImporter; + +function isFileImporter(value: Importers): value is FileImporter { + return 'findFileUrl' in value; +} + +/** + * A Sass renderer implementation that uses the persistent Dart Sass embedded compiler + * daemon (`sass-embedded`) communicating over standard input/output with protocol buffers, + * or falls back to the pure-JS Dart Sass async compiler (`sass.initAsyncCompiler()`). + */ +export class SassCompiler { + #asyncCompiler: AsyncCompiler | undefined; + #asyncCompilerPromise: Promise | undefined; + readonly #directoryCache = new Map(); + + constructor(private readonly rebase = false) {} + + async #createAsyncCompiler(): Promise { + if (useSassEmbedded) { + const { initAsyncCompiler } = await import('sass-embedded'); + + return initAsyncCompiler(); + } + + const { initAsyncCompiler } = await import('sass'); + + return initAsyncCompiler() as unknown as Promise; + } + + async #ensureAsyncCompiler(): Promise { + if (this.#asyncCompiler) { + return this.#asyncCompiler; + } + + this.#asyncCompilerPromise ??= this.#createAsyncCompiler(); + + try { + this.#asyncCompiler = await this.#asyncCompilerPromise; + } finally { + this.#asyncCompilerPromise = undefined; + } + + return this.#asyncCompiler; + } + + /** + * Provides information about the Sass implementation. + * This mimics enough of the `sass-embedded` or `sass` value to be used with the `sass-loader`. + */ + get info(): string { + return useSassEmbedded ? 'sass-embedded\tasync-compiler' : 'dart-sass\tasync-compiler'; + } + + /** + * The synchronous render function is not used by the `sass-loader`. + */ + compileString(): never { + throw new Error('Sass compileString is not supported.'); + } + + /** + * Asynchronously request a Sass stylesheet to be rendered using the native embedded compiler + * or the pure-JS async compiler fallback. + * + * @param source The contents to compile. + * @param options The `sass` / `sass-embedded` options to use when rendering the stylesheet. + */ + async compileStringAsync( + source: string, + options: StringOptions<'async'>, + ): Promise { + // The CLI's configuration does not use or expose the ability to define custom Sass functions + if (options.functions && Object.keys(options.functions).length > 0) { + throw new Error('Sass custom functions are not supported.'); + } + + const compiler = await this.#ensureAsyncCompiler(); + + if (!this.rebase) { + return compiler.compileStringAsync(source, options); + } + + const { functions, importers, importer, url, logger, ...serializableOptions } = options; + + let finalImporters: + (Importer<'async'> | FileImporter<'async'> | NodePackageImporter)[] | undefined; + let loadPaths = options.loadPaths; + const entryDirectory = url ? dirname(fileURLToPath(url)) : process.cwd(); + const directoryCache = this.#directoryCache; + const rebaseSourceMaps = options.sourceMap ? new Map() : undefined; + + if (importers?.length) { + if (importers.some((i) => !isFileImporter(i))) { + throw new Error('Only File Importers are supported.'); + } + + finalImporters = [ + new AsyncModuleUrlRebasingImporter( + entryDirectory, + directoryCache, + rebaseSourceMaps, + async (specifier: string, options: CanonicalizeContext): Promise => { + for (const importer of importers) { + const result = await (importer as FileImporter<'async'>).findFileUrl( + specifier, + options, + ); + if (result) { + return result; + } + } + + return null; + }, + ), + ]; + } + + if (loadPaths?.length) { + finalImporters ??= []; + finalImporters.push( + new LoadPathsUrlRebasingImporter( + entryDirectory, + directoryCache, + rebaseSourceMaps, + loadPaths, + ), + ); + loadPaths = undefined; + } + + const relativeImporter = new RelativeUrlRebasingImporter( + entryDirectory, + directoryCache, + rebaseSourceMaps, + ); + + const result = await compiler.compileStringAsync(source, { + ...serializableOptions, + url, + loadPaths, + importers: finalImporters, + importer: relativeImporter, + logger, + }); + + if (result.sourceMap && rebaseSourceMaps?.size) { + result.sourceMap = mergeSourceMaps( + result.sourceMap as unknown as RawSourceMap, + (file, context) => (file !== context.importer ? rebaseSourceMaps.get(file) : null), + ) as unknown as typeof result.sourceMap; + } + + return result; + } + + /** + * Clear the directory cache. + */ + clearCache(): void { + this.#directoryCache.clear(); + } + + /** + * Shutdown the Sass compiler. + * @returns A void promise that resolves when closing is complete. + */ + async close(): Promise { + this.clearCache(); + + if (this.#asyncCompilerPromise) { + try { + await this.#ensureAsyncCompiler(); + } catch { + // Ignore compiler initialization failures on shutdown + } + } + + if (this.#asyncCompiler) { + const compiler = this.#asyncCompiler; + this.#asyncCompiler = undefined; + await compiler.dispose(); + } + } +} diff --git a/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts new file mode 100644 index 000000000000..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('`, + ); + }); + + it('should update script src and link href attributes when option is set to absolute URL', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + deployUrl: 'https://example.com/some/path/', + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness + .expectFile('dist/index.html') + .content.toEqual( + `` + + `` + + ``, + ); + }); + + it('should update resources component stylesheets to reference deployURL', async () => { + await harness.writeFile('src/app/test.svg', ''); + await harness.writeFile( + 'src/app/app.component.css', + `* { background-image: url('./test.svg'); }`, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + deployUrl: 'https://example.com/some/path/', + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness + .expectFile('dist/main.js') + .content.toContain('background-image: url("https://example.com/some/path/media/test.svg")'); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/tests/options/main_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/tests/options/main_spec.ts new file mode 100644 index 000000000000..1111ed01554a --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/tests/options/main_spec.ts @@ -0,0 +1,90 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildEsbuildBrowser } from '../../index'; +import { BASE_OPTIONS, BROWSER_BUILDER_INFO, describeBuilder } from '../setup'; + +describeBuilder(buildEsbuildBrowser, BROWSER_BUILDER_INFO, (harness) => { + describe('Option: "main"', () => { + it('uses a provided TypeScript file', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + main: 'src/main.ts', + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/main.js').toExist(); + harness.expectFile('dist/index.html').toExist(); + }); + + it('uses a provided JavaScript file', async () => { + await harness.writeFile('src/main.js', `console.log('main');`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + main: 'src/main.js', + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/main.js').toExist(); + harness.expectFile('dist/index.html').toExist(); + + harness.expectFile('dist/main.js').content.toContain('console.log("main")'); + }); + + it('fails and shows an error when file does not exist', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + main: 'src/missing.ts', + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBe(false); + expect(logs).toContain( + jasmine.objectContaining({ message: jasmine.stringMatching('Could not resolve "') }), + ); + + harness.expectFile('dist/main.js').toNotExist(); + harness.expectFile('dist/index.html').toNotExist(); + }); + + it('throws an error when given an empty string', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + main: '', + }); + + const { result, error } = await harness.executeOnce({ outputLogsOnException: false }); + expect(result).toBeUndefined(); + + expect(error?.message).toContain('cannot be an empty string'); + }); + + it('resolves an absolute path as relative inside the workspace root', async () => { + await harness.writeFile('file.mjs', `console.log('Hello!');`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + main: '/file.mjs', + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // Always uses the name `main.js` for the `main` option. + harness.expectFile('dist/main.js').toExist(); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/tests/setup.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/tests/setup.ts new file mode 100644 index 000000000000..446e5a5546d1 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/tests/setup.ts @@ -0,0 +1,32 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Schema } from '../schema'; + +export { describeBuilder } from '../../../testing'; + +export const BROWSER_BUILDER_INFO = Object.freeze({ + name: '@angular-devkit/build-angular:browser-esbuild', + schemaPath: __dirname + '/../schema.json', +}); + +/** + * Contains all required browser builder fields. + * Also disables progress reporting to minimize logging output. + */ +export const BASE_OPTIONS = Object.freeze({ + index: 'src/index.html', + main: 'src/main.ts', + outputPath: 'dist', + tsConfig: 'src/tsconfig.app.json', + progress: false, + + // Disable optimizations + optimization: false, + buildOptimizer: false, +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/index.ts b/packages/angular_devkit/build_angular/src/builders/browser/index.ts new file mode 100644 index 000000000000..e8fd07c82830 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/index.ts @@ -0,0 +1,465 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/* eslint-disable max-lines-per-function */ + +import { + BudgetCalculatorResult, + FileInfo, + IndexHtmlGenerator, + IndexHtmlTransform, + ThresholdSeverity, + assertCompatibleAngularVersion, + augmentAppWithServiceWorker, + checkBudgets, + purgeStaleBuildCache, +} from '@angular/build/private'; +import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect'; +import { EmittedFiles, WebpackLoggingCallback, runWebpack } from '@angular-devkit/build-webpack'; +import { imageDomains } from '@ngtools/webpack'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { Observable, concatMap, from, map, switchMap } from 'rxjs'; +import webpack, { StatsCompilation } from 'webpack'; +import { getCommonConfig, getStylesConfig } from '../../tools/webpack/configs'; +import { markAsyncChunksNonInitial } from '../../tools/webpack/utils/async-chunks'; +import { normalizeExtraEntryPoints } from '../../tools/webpack/utils/helpers'; +import { + BuildEventStats, + generateBuildEventStats, + statsErrorsToString, + statsHasErrors, + statsHasWarnings, + statsWarningsToString, + webpackStatsLogger, +} from '../../tools/webpack/utils/stats'; +import { ExecutionTransformer } from '../../transforms'; +import { + deleteOutputDir, + normalizeAssetPatterns, + normalizeOptimization, + urlJoin, +} from '../../utils'; +import { copyAssets } from '../../utils/copy-assets'; +import { assertIsError } from '../../utils/error'; +import { i18nInlineEmittedFiles } from '../../utils/i18n-inlining'; +import { I18nOptions } from '../../utils/i18n-webpack'; +import { normalizeCacheOptions } from '../../utils/normalize-cache'; +import { ensureOutputPaths } from '../../utils/output-paths'; +import { generateEntryPoints } from '../../utils/package-chunk-sort'; +import { Spinner } from '../../utils/spinner'; +import { + generateI18nBrowserWebpackConfigFromContext, + getIndexInputFile, + getIndexOutputFile, +} from '../../utils/webpack-browser-config'; +import { Schema as BrowserBuilderSchema } from './schema'; + +/** + * @experimental Direct usage of this type is considered experimental. + */ +export type BrowserBuilderOutput = BuilderOutput & { + stats: BuildEventStats; + baseOutputPath: string; + outputs: { + locale?: string; + path: string; + baseHref?: string; + }[]; +}; + +/** + * Maximum time in milliseconds for single build/rebuild + * This accounts for CI variability. + */ +export const BUILD_TIMEOUT = 30_000; + +async function initialize( + options: BrowserBuilderSchema, + context: BuilderContext, + webpackConfigurationTransform?: ExecutionTransformer, +): Promise<{ + config: webpack.Configuration; + projectRoot: string; + projectSourceRoot?: string; + i18n: I18nOptions; +}> { + const originalOutputPath = options.outputPath; + + // Assets are processed directly by the builder except when watching + const adjustedOptions = options.watch ? options : { ...options, assets: [] }; + + const { config, projectRoot, projectSourceRoot, i18n } = + await generateI18nBrowserWebpackConfigFromContext(adjustedOptions, context, (wco) => [ + getCommonConfig(wco), + getStylesConfig(wco), + ]); + + let transformedConfig; + if (webpackConfigurationTransform) { + transformedConfig = await webpackConfigurationTransform(config); + } + + if (options.deleteOutputPath) { + await deleteOutputDir(context.workspaceRoot, originalOutputPath); + } + + return { config: transformedConfig || config, projectRoot, projectSourceRoot, i18n }; +} + +/** + * @experimental Direct usage of this function is considered experimental. + */ +export function buildWebpackBrowser( + options: BrowserBuilderSchema, + context: BuilderContext, + transforms: { + webpackConfiguration?: ExecutionTransformer; + logging?: WebpackLoggingCallback; + indexHtml?: IndexHtmlTransform; + } = {}, +): Observable { + const projectName = context.target?.project; + if (!projectName) { + throw new Error('The builder requires a target.'); + } + + context.logger.warn( + 'The "@angular-devkit/build-angular:browser" builder is deprecated as part of Angular\'s Webpack support deprecation. ' + + 'Use "@angular/build:application" instead. For more information, see https://angular.dev/tools/cli/build-system-migration.', + ); + + const baseOutputPath = path.resolve(context.workspaceRoot, options.outputPath); + let outputPaths: undefined | Map; + + // Check Angular version. + assertCompatibleAngularVersion(context.workspaceRoot); + + return from(context.getProjectMetadata(projectName)).pipe( + switchMap(async (projectMetadata) => { + // Purge old build disk cache. + await purgeStaleBuildCache(context); + + // Initialize builder + const initialization = await initialize(options, context, transforms.webpackConfiguration); + + // Add index file to watched files. + if (options.watch) { + const indexInputFile = path.join(context.workspaceRoot, getIndexInputFile(options.index)); + initialization.config.plugins ??= []; + initialization.config.plugins.push({ + apply: (compiler: webpack.Compiler) => { + compiler.hooks.thisCompilation.tap('build-angular', (compilation) => { + compilation.fileDependencies.add(indexInputFile); + }); + }, + }); + } + + return { + ...initialization, + cacheOptions: normalizeCacheOptions(projectMetadata, context.workspaceRoot), + }; + }), + switchMap(({ config, projectRoot, projectSourceRoot, i18n, cacheOptions }) => { + const normalizedOptimization = normalizeOptimization(options.optimization); + + return runWebpack(config, context, { + webpackFactory: require('webpack') as typeof webpack, + logging: + transforms.logging || + ((stats, config) => { + if (options.verbose && config.stats !== false) { + const statsOptions = config.stats === true ? undefined : config.stats; + context.logger.info(stats.toString(statsOptions)); + } + }), + }).pipe( + concatMap( + async ( + buildEvent, + ): Promise<{ output: BuilderOutput; webpackStats: StatsCompilation }> => { + const spinner = new Spinner(); + spinner.enabled = options.progress !== false; + + const { success, emittedFiles = [], outputPath: webpackOutputPath } = buildEvent; + const webpackRawStats = buildEvent.webpackStats; + if (!webpackRawStats) { + throw new Error('Webpack stats build result is required.'); + } + + // Fix incorrectly set `initial` value on chunks. + const extraEntryPoints = [ + ...normalizeExtraEntryPoints(options.styles || [], 'styles'), + ...normalizeExtraEntryPoints(options.scripts || [], 'scripts'), + ]; + + const webpackStats = { + ...webpackRawStats, + chunks: markAsyncChunksNonInitial(webpackRawStats, extraEntryPoints), + }; + + if (!success) { + // If using bundle downleveling then there is only one build + // If it fails show any diagnostic messages and bail + if (statsHasWarnings(webpackStats)) { + context.logger.warn(statsWarningsToString(webpackStats, { colors: true })); + } + if (statsHasErrors(webpackStats)) { + context.logger.error(statsErrorsToString(webpackStats, { colors: true })); + } + + return { + webpackStats: webpackRawStats, + output: { success: false }, + }; + } else { + outputPaths = ensureOutputPaths(baseOutputPath, i18n); + + const scriptsEntryPointName = normalizeExtraEntryPoints( + options.scripts || [], + 'scripts', + ).map((x) => x.bundleName); + + if (i18n.shouldInline) { + const success = await i18nInlineEmittedFiles( + context, + emittedFiles, + i18n, + baseOutputPath, + Array.from(outputPaths.values()), + scriptsEntryPointName, + webpackOutputPath, + options.i18nMissingTranslation, + ); + if (!success) { + return { + webpackStats: webpackRawStats, + output: { success: false }, + }; + } + } + + // Check for budget errors and display them to the user. + const budgets = options.budgets; + let budgetFailures: BudgetCalculatorResult[] | undefined; + if (budgets?.length) { + budgetFailures = [...checkBudgets(budgets, webpackStats)]; + for (const { severity, message } of budgetFailures) { + switch (severity) { + case ThresholdSeverity.Warning: + webpackStats.warnings?.push({ message }); + break; + case ThresholdSeverity.Error: + webpackStats.errors?.push({ message }); + break; + default: + assertNever(severity); + } + } + } + + const buildSuccess = success && !statsHasErrors(webpackStats); + if (buildSuccess) { + // Copy assets + if (!options.watch && options.assets?.length) { + spinner.start('Copying assets...'); + try { + await copyAssets( + normalizeAssetPatterns( + options.assets, + context.workspaceRoot, + projectRoot, + projectSourceRoot, + ), + Array.from(outputPaths.values()), + context.workspaceRoot, + ); + spinner.succeed('Copying assets complete.'); + } catch (err) { + spinner.fail('Copying of assets failed.'); + assertIsError(err); + + return { + output: { + success: false, + error: 'Unable to copy assets: ' + err.message, + }, + webpackStats: webpackRawStats, + }; + } + } + + if (options.index) { + spinner.start('Generating index html...'); + + const entrypoints = generateEntryPoints({ + scripts: options.scripts ?? [], + styles: options.styles ?? [], + }); + + let hasErrors = false; + for (const [locale, outputPath] of outputPaths.entries()) { + try { + const indexHtmlGenerator = new IndexHtmlGenerator({ + cache: cacheOptions, + indexPath: path.join( + context.workspaceRoot, + getIndexInputFile(options.index), + ), + entrypoints, + outputPath, + deployUrl: options.deployUrl, + sri: options.subresourceIntegrity, + optimization: normalizedOptimization, + crossOrigin: options.crossOrigin, + postTransform: transforms.indexHtml, + imageDomains: Array.from(imageDomains), + }); + + const { + csrContent: content, + warnings, + errors, + } = await indexHtmlGenerator.process({ + baseHref: getLocaleBaseHref(i18n, locale) ?? options.baseHref, + // i18nLocale is used when Ivy is disabled + lang: locale || undefined, + files: mapEmittedFilesToFileInfo(emittedFiles), + }); + + if (warnings.length || errors.length) { + spinner.stop(); + warnings.forEach((m) => context.logger.warn(m)); + errors.forEach((m) => { + context.logger.error(m); + hasErrors = true; + }); + spinner.start(); + } + + const indexOutput = path.join(outputPath, getIndexOutputFile(options.index)); + await fs.promises.mkdir(path.dirname(indexOutput), { recursive: true }); + await fs.promises.writeFile(indexOutput, content); + } catch (error) { + spinner.fail('Index html generation failed.'); + assertIsError(error); + + return { + webpackStats: webpackRawStats, + output: { success: false, error: error.message }, + }; + } + } + + if (hasErrors) { + spinner.fail('Index html generation failed.'); + + return { + webpackStats: webpackRawStats, + output: { success: false }, + }; + } else { + spinner.succeed('Index html generation complete.'); + } + } + + if (options.serviceWorker) { + spinner.start('Generating service worker...'); + for (const [locale, outputPath] of outputPaths.entries()) { + try { + await augmentAppWithServiceWorker( + projectRoot, + context.workspaceRoot, + outputPath, + getLocaleBaseHref(i18n, locale) ?? options.baseHref ?? '/', + options.ngswConfigPath, + ); + } catch (error) { + spinner.fail('Service worker generation failed.'); + assertIsError(error); + + return { + webpackStats: webpackRawStats, + output: { success: false, error: error.message }, + }; + } + } + + spinner.succeed('Service worker generation complete.'); + } + } + + webpackStatsLogger(context.logger, webpackStats, config, budgetFailures); + + return { + webpackStats: webpackRawStats, + output: { success: buildSuccess }, + }; + } + }, + ), + map( + ({ output: event, webpackStats }) => + ({ + ...event, + stats: generateBuildEventStats(webpackStats, options), + baseOutputPath, + outputs: (outputPaths && + [...outputPaths.entries()].map(([locale, path]) => ({ + locale, + path, + baseHref: getLocaleBaseHref(i18n, locale) ?? options.baseHref, + }))) || { + path: baseOutputPath, + baseHref: options.baseHref, + }, + }) as BrowserBuilderOutput, + ), + ); + }), + ); + + function getLocaleBaseHref(i18n: I18nOptions, locale: string): string | undefined { + if (i18n.flatOutput) { + return undefined; + } + + const localeData = i18n.locales[locale]; + if (!localeData) { + return undefined; + } + + const baseHrefSuffix = localeData.baseHref ?? localeData.subPath + '/'; + + return baseHrefSuffix !== '' ? urlJoin(options.baseHref || '', baseHrefSuffix) : undefined; + } +} + +function assertNever(input: never): never { + throw new Error( + `Unexpected call to assertNever() with input: ${JSON.stringify( + input, + null /* replacer */, + 4 /* tabSize */, + )}`, + ); +} + +function mapEmittedFilesToFileInfo(files: EmittedFiles[] = []): FileInfo[] { + const filteredFiles: FileInfo[] = []; + for (const { file, name, extension, initial } of files) { + if (name && initial) { + filteredFiles.push({ file, extension, name }); + } + } + + return filteredFiles; +} + +export default createBuilder(buildWebpackBrowser); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/schema.json b/packages/angular_devkit/build_angular/src/builders/browser/schema.json new file mode 100644 index 000000000000..301eeafcc4f1 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/schema.json @@ -0,0 +1,548 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "title": "Webpack browser schema for Build Facade.", + "description": "Browser target options", + "type": "object", + "properties": { + "assets": { + "type": "array", + "description": "Define the assets to be copied to the output directory. These assets are copied as-is without any further processing or hashing.", + "default": [], + "items": { + "$ref": "#/definitions/assetPattern" + } + }, + "main": { + "type": "string", + "description": "The full path for the main entry point to the app, relative to the current workspace." + }, + "polyfills": { + "description": "Polyfills to be included in the build.", + "oneOf": [ + { + "type": "array", + "description": "A list of polyfills to include in the build. Can be a full path for a file, relative to the current workspace or module specifier. Example: 'zone.js'.", + "items": { + "type": "string", + "uniqueItems": true + }, + "default": [] + }, + { + "type": "string", + "description": "The full path for the polyfills file, relative to the current workspace or a module specifier. Example: 'zone.js'." + } + ] + }, + "tsConfig": { + "type": "string", + "description": "The full path for the TypeScript configuration file, relative to the current workspace." + }, + "scripts": { + "description": "Global scripts to be included in the build.", + "type": "array", + "default": [], + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "The file to include.", + "pattern": "\\.[cm]?jsx?$" + }, + "bundleName": { + "type": "string", + "pattern": "^[\\w\\-.]*$", + "description": "The bundle name for this extra entry point." + }, + "inject": { + "type": "boolean", + "description": "If the bundle will be referenced in the HTML file.", + "default": true + } + }, + "additionalProperties": false, + "required": ["input"] + }, + { + "type": "string", + "description": "The file to include.", + "pattern": "\\.[cm]?jsx?$" + } + ] + } + }, + "styles": { + "description": "Global styles to be included in the build.", + "type": "array", + "default": [], + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "The file to include.", + "pattern": "\\.(?:css|scss|sass|less)$" + }, + "bundleName": { + "type": "string", + "pattern": "^[\\w\\-.]*$", + "description": "The bundle name for this extra entry point." + }, + "inject": { + "type": "boolean", + "description": "If the bundle will be referenced in the HTML file.", + "default": true + } + }, + "additionalProperties": false, + "required": ["input"] + }, + { + "type": "string", + "description": "The file to include.", + "pattern": "\\.(?:css|scss|sass|less)$" + } + ] + } + }, + "inlineStyleLanguage": { + "description": "The stylesheet language to use for the application's inline component styles.", + "type": "string", + "default": "css", + "enum": ["css", "less", "sass", "scss"] + }, + "stylePreprocessorOptions": { + "description": "Options to pass to style preprocessors.", + "type": "object", + "properties": { + "includePaths": { + "description": "Paths to include. Paths will be resolved to workspace root.", + "type": "array", + "items": { + "type": "string" + }, + "default": [] + } + }, + "additionalProperties": false + }, + "optimization": { + "description": "Enables optimization of the build output. Including minification of scripts and styles, tree-shaking, dead-code elimination, inlining of critical CSS and fonts inlining. For more information, see https://angular.dev/reference/configs/workspace-config#optimization-configuration.", + "default": true, + "x-user-analytics": "ep.ng_optimization", + "oneOf": [ + { + "type": "object", + "properties": { + "scripts": { + "type": "boolean", + "description": "Enables optimization of the scripts output.", + "default": true + }, + "styles": { + "description": "Enables optimization of the styles output.", + "default": true, + "oneOf": [ + { + "type": "object", + "properties": { + "minify": { + "type": "boolean", + "description": "Minify CSS definitions by removing extraneous whitespace and comments, merging identifiers and minimizing values.", + "default": true + }, + "inlineCritical": { + "type": "boolean", + "description": "Extract and inline critical CSS definitions to improve first paint time.", + "default": true + } + }, + "additionalProperties": false + }, + { + "type": "boolean" + } + ] + }, + "fonts": { + "description": "Enables optimization for fonts. This option requires internet access. `HTTPS_PROXY` environment variable can be used to specify a proxy server.", + "default": true, + "oneOf": [ + { + "type": "object", + "properties": { + "inline": { + "type": "boolean", + "description": "Reduce render blocking requests by inlining external Google Fonts and Adobe Fonts CSS definitions in the application's HTML index file. This option requires internet access. `HTTPS_PROXY` environment variable can be used to specify a proxy server.", + "default": true + } + }, + "additionalProperties": false + }, + { + "type": "boolean" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "boolean" + } + ] + }, + "fileReplacements": { + "description": "Replace compilation source files with other compilation source files in the build.", + "type": "array", + "items": { + "$ref": "#/definitions/fileReplacement" + }, + "default": [] + }, + "outputPath": { + "type": "string", + "description": "The full path for the new output directory, relative to the current workspace." + }, + "resourcesOutputPath": { + "type": "string", + "description": "The path where style resources will be placed, relative to outputPath." + }, + "aot": { + "type": "boolean", + "description": "Build using Ahead of Time compilation.", + "x-user-analytics": "ep.ng_aot", + "default": true + }, + "sourceMap": { + "description": "Output source maps for scripts and styles. For more information, see https://angular.dev/reference/configs/workspace-config#source-map-configuration.", + "default": false, + "oneOf": [ + { + "type": "object", + "properties": { + "scripts": { + "type": "boolean", + "description": "Output source maps for all scripts.", + "default": true + }, + "styles": { + "type": "boolean", + "description": "Output source maps for all styles.", + "default": true + }, + "hidden": { + "type": "boolean", + "description": "Output source maps used for error reporting tools.", + "default": false + }, + "vendor": { + "type": "boolean", + "description": "Resolve vendor packages source maps.", + "default": false + } + }, + "additionalProperties": false + }, + { + "type": "boolean" + } + ] + }, + "vendorChunk": { + "type": "boolean", + "description": "Generate a seperate bundle containing only vendor libraries. This option should only be used for development to reduce the incremental compilation time.", + "default": false + }, + "commonChunk": { + "type": "boolean", + "description": "Generate a seperate bundle containing code used across multiple bundles.", + "default": true + }, + "baseHref": { + "type": "string", + "description": "Base url for the application being built." + }, + "deployUrl": { + "type": "string", + "description": "Customize the base path for the URLs of resources in 'index.html' and component stylesheets. This option is only necessary for specific deployment scenarios, such as with Angular Elements or when utilizing different CDN locations." + }, + "verbose": { + "type": "boolean", + "description": "Adds more details to output logging.", + "default": false + }, + "progress": { + "type": "boolean", + "description": "Log progress to the console while building.", + "default": true + }, + "i18nMissingTranslation": { + "type": "string", + "description": "How to handle missing translations for i18n.", + "enum": ["warning", "error", "ignore"], + "default": "warning" + }, + "i18nDuplicateTranslation": { + "type": "string", + "description": "How to handle duplicate translations for i18n.", + "enum": ["warning", "error", "ignore"], + "default": "warning" + }, + "localize": { + "description": "Translate the bundles in one or more locales.", + "oneOf": [ + { + "type": "boolean", + "description": "Translate all locales." + }, + { + "type": "array", + "description": "List of locales ID's to translate.", + "minItems": 1, + "items": { + "type": "string", + "pattern": "^[a-zA-Z]{2,3}(-[a-zA-Z]{4})?(-([a-zA-Z]{2}|[0-9]{3}))?(-[a-zA-Z]{5,8})?(-x(-[a-zA-Z0-9]{1,8})+)?$" + } + } + ] + }, + "watch": { + "type": "boolean", + "description": "Run build when files change.", + "default": false + }, + "outputHashing": { + "type": "string", + "description": "Define the output filename cache-busting hashing mode.\n\n- `none`: No hashing.\n- `all`: Hash for all output bundles. \n- `media`: Hash for all output media (e.g., images, fonts, etc. that are referenced in CSS files).\n- `bundles`: Hash for output of lazy and main bundles.", + "default": "none", + "enum": ["none", "all", "media", "bundles"] + }, + "poll": { + "type": "number", + "description": "Enable and define the file watching poll time period in milliseconds." + }, + "deleteOutputPath": { + "type": "boolean", + "description": "Delete the output path before building.", + "default": true + }, + "preserveSymlinks": { + "type": "boolean", + "description": "Do not use the real path when resolving modules. If unset then will default to `true` if NodeJS option --preserve-symlinks is set." + }, + "extractLicenses": { + "type": "boolean", + "description": "Extract all licenses in a separate file.", + "default": true + }, + "buildOptimizer": { + "type": "boolean", + "description": "Enables advanced build optimizations when using the 'aot' option.", + "default": true + }, + "namedChunks": { + "type": "boolean", + "description": "Use file name for lazy loaded chunks.", + "default": false + }, + "subresourceIntegrity": { + "type": "boolean", + "description": "Enables the use of subresource integrity validation.", + "default": false + }, + "serviceWorker": { + "type": "boolean", + "description": "Generates a service worker config for production builds.", + "default": false + }, + "ngswConfigPath": { + "type": "string", + "description": "Path to ngsw-config.json." + }, + "index": { + "description": "Configures the generation of the application's HTML index.", + "oneOf": [ + { + "type": "string", + "description": "The path of a file to use for the application's HTML index. The filename of the specified path will be used for the generated file and will be created in the root of the application's configured output path." + }, + { + "type": "object", + "description": "", + "properties": { + "input": { + "type": "string", + "minLength": 1, + "description": "The path of a file to use for the application's generated HTML index." + }, + "output": { + "type": "string", + "minLength": 1, + "default": "index.html", + "description": "The output path of the application's generated HTML index file. The full provided path will be used and will be considered relative to the application's configured output path." + } + }, + "required": ["input"] + } + ] + }, + "statsJson": { + "type": "boolean", + "description": "Generates a 'stats.json' file which can be analyzed using tools such as 'webpack-bundle-analyzer'.", + "default": false + }, + "budgets": { + "description": "Budget thresholds to ensure parts of your application stay within boundaries which you set.", + "type": "array", + "items": { + "$ref": "#/definitions/budget" + }, + "default": [] + }, + "webWorkerTsConfig": { + "type": "string", + "description": "TypeScript configuration for Web Worker modules." + }, + "crossOrigin": { + "type": "string", + "description": "Define the crossorigin attribute setting of elements that provide CORS support.", + "default": "none", + "enum": ["none", "anonymous", "use-credentials"] + }, + "allowedCommonJsDependencies": { + "description": "A list of CommonJS or AMD packages that are allowed to be used without a build time warning. Use `'*'` to allow all.", + "type": "array", + "items": { + "type": "string" + }, + "default": [] + } + }, + "additionalProperties": false, + "required": ["outputPath", "index", "main", "tsConfig"], + "definitions": { + "assetPattern": { + "oneOf": [ + { + "type": "object", + "properties": { + "followSymlinks": { + "type": "boolean", + "default": false, + "description": "Allow glob patterns to follow symlink directories. This allows subdirectories of the symlink to be searched." + }, + "glob": { + "type": "string", + "description": "The pattern to match." + }, + "input": { + "type": "string", + "description": "The input directory path in which to apply 'glob'. Defaults to the project root." + }, + "ignore": { + "description": "An array of globs to ignore.", + "type": "array", + "items": { + "type": "string" + } + }, + "output": { + "type": "string", + "default": "", + "description": "Absolute path within the output." + } + }, + "additionalProperties": false, + "required": ["glob", "input"] + }, + { + "type": "string" + } + ] + }, + "fileReplacement": { + "oneOf": [ + { + "type": "object", + "properties": { + "src": { + "type": "string", + "pattern": "\\.(([cm]?[jt])sx?|json)$" + }, + "replaceWith": { + "type": "string", + "pattern": "\\.(([cm]?[jt])sx?|json)$" + } + }, + "additionalProperties": false, + "required": ["src", "replaceWith"] + }, + { + "type": "object", + "properties": { + "replace": { + "type": "string", + "pattern": "\\.(([cm]?[jt])sx?|json)$" + }, + "with": { + "type": "string", + "pattern": "\\.(([cm]?[jt])sx?|json)$" + } + }, + "additionalProperties": false, + "required": ["replace", "with"] + } + ] + }, + "budget": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "The type of budget.", + "enum": ["all", "allScript", "any", "anyScript", "anyComponentStyle", "bundle", "initial"] + }, + "name": { + "type": "string", + "description": "The name of the bundle." + }, + "baseline": { + "type": "string", + "description": "The baseline size for comparison." + }, + "maximumWarning": { + "type": "string", + "description": "The maximum threshold for warning relative to the baseline." + }, + "maximumError": { + "type": "string", + "description": "The maximum threshold for error relative to the baseline." + }, + "minimumWarning": { + "type": "string", + "description": "The minimum threshold for warning relative to the baseline." + }, + "minimumError": { + "type": "string", + "description": "The minimum threshold for error relative to the baseline." + }, + "warning": { + "type": "string", + "description": "The threshold for warning relative to the baseline (min & max)." + }, + "error": { + "type": "string", + "description": "The threshold for error relative to the baseline (min & max)." + } + }, + "additionalProperties": false, + "required": ["type"] + } + } +} diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/allow-js_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/allow-js_spec.ts new file mode 100644 index 000000000000..2596ecd94c39 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/allow-js_spec.ts @@ -0,0 +1,122 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; + +import { join, normalize, relative } from '@angular-devkit/core'; +import { Observable, lastValueFrom, take, tap } from 'rxjs'; +import { createArchitect, host } from '../../../testing/test-utils'; +import { BrowserBuilderOutput } from '../index'; + +describe('Browser Builder allow js', () => { + const targetSpec = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + afterEach(async () => host.restore().toPromise()); + + it('works', async () => { + host.writeMultipleFiles({ + 'src/my-js-file.js': `console.log(1); export const a = 2;`, + 'src/main.ts': `import { a } from './my-js-file'; console.log(a);`, + }); + + host.replaceInFile( + 'tsconfig.json', + '"target": "es2022"', + '"target": "es2022", "allowJs": true', + ); + + const run = await architect.scheduleTarget(targetSpec); + const output = await run.result; + expect(output.success).toBe(true); + + const content = new TextDecoder().decode( + await lastValueFrom(host.read(join(normalize(output.outputs[0].path), 'main.js'))), + ); + + expect(content).toContain('const a = 2'); + + await run.stop(); + }); + + it('works with aot', async () => { + host.writeMultipleFiles({ + 'src/my-js-file.js': `console.log(1); export const a = 2;`, + 'src/main.ts': `import { a } from './my-js-file'; console.log(a);`, + }); + + host.replaceInFile( + 'tsconfig.json', + '"target": "es2022"', + '"target": "es2022", "allowJs": true', + ); + + const overrides = { aot: true }; + + const run = await architect.scheduleTarget(targetSpec, overrides); + const output = await run.result; + expect(output.success).toBe(true); + + const content = new TextDecoder().decode( + await lastValueFrom(host.read(join(normalize(output.outputs[0].path), 'main.js'))), + ); + + expect(content).toContain('const a = 2'); + + await run.stop(); + }); + + it('works with watch', async () => { + host.writeMultipleFiles({ + 'src/my-js-file.js': `console.log(1); export const a = 2;`, + 'src/main.ts': `import { a } from './my-js-file'; console.log(a);`, + }); + + host.replaceInFile( + 'tsconfig.json', + '"target": "es2022"', + '"target": "es2022", "allowJs": true', + ); + + const overrides = { watch: true }; + + let buildCount = 1; + const run = await architect.scheduleTarget(targetSpec, overrides); + + await lastValueFrom( + run.output.pipe( + tap((output) => { + const path = relative(host.root(), join(normalize(output.outputs[0].path), 'main.js')); + const content = new TextDecoder().decode(host.scopedSync().read(path)); + + switch (buildCount) { + case 1: + expect(content).toContain('const a = 2'); + host.writeMultipleFiles({ + 'src/my-js-file.js': `console.log(1); export const a = 1;`, + }); + break; + case 2: + expect(content).toContain('const a = 1'); + break; + } + + buildCount++; + }), + tap((buildEvent) => expect(buildEvent.success).toBe(true)), + take(2), + ), + ); + + await run.stop(); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/base-href_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/base-href_spec.ts new file mode 100644 index 000000000000..038d35f3003e --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/base-href_spec.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { join, normalize, tags } from '@angular-devkit/core'; +import { lastValueFrom } from 'rxjs'; +import { createArchitect, host } from '../../../testing/test-utils'; + +describe('Browser Builder base href', () => { + const targetSpec = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + afterEach(async () => host.restore().toPromise()); + + it('works', async () => { + host.replaceInFile( + 'tsconfig.json', + '"target": "es2022"', + '"target": "es2022", "allowJs": true', + ); + + host.writeMultipleFiles({ + 'src/my-js-file.js': `console.log(1); export const a = 2;`, + 'src/main.ts': `import { a } from './my-js-file'; console.log(a);`, + }); + + const overrides = { baseHref: '/myUrl' }; + const run = await architect.scheduleTarget(targetSpec, overrides); + const output = await run.result; + + expect(output.success).toBe(true); + const fileName = join(normalize(output.outputs[0].path), 'index.html'); + const content = new TextDecoder().decode(await lastValueFrom(host.read(fileName))); + expect(content).toMatch(//); + + await run.stop(); + }); + + it('should not override base href in HTML when option is not set', async () => { + host.writeMultipleFiles({ + 'src/index.html': ` + + + + + `, + }); + + const run = await architect.scheduleTarget(targetSpec); + const output = await run.result; + + expect(output.success).toBeTrue(); + const fileName = join(normalize(output.outputs[0].path), 'index.html'); + const content = new TextDecoder().decode(await lastValueFrom(host.read(fileName))); + expect(content).toContain(``); + + await run.stop(); + }); + + it('should insert base href in the correct position', async () => { + host.writeMultipleFiles({ + 'src/index.html': tags.oneLine` + + + `, + }); + + const overrides = { baseHref: '/myUrl' }; + const run = await architect.scheduleTarget(targetSpec, overrides); + const output = await run.result; + expect(output.success).toBe(true); + const fileName = join(normalize(output.outputs[0].path), 'index.html'); + const content = new TextDecoder().decode(await lastValueFrom(host.read(normalize(fileName)))); + expect(content).toContain(''); + await run.stop(); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/build-optimizer_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/build-optimizer_spec.ts new file mode 100644 index 000000000000..b6c7be1fd931 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/build-optimizer_spec.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { join, normalize } from '@angular-devkit/core'; +import { lastValueFrom } from 'rxjs'; +import { browserBuild, createArchitect, host } from '../../../testing/test-utils'; +import { BrowserBuilderOutput } from '../index'; + +describe('Browser Builder build optimizer', () => { + const targetSpec = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + afterEach(async () => host.restore().toPromise()); + + it('works', async () => { + const overrides = { aot: true, buildOptimizer: true }; + const { files } = await browserBuild(architect, host, targetSpec, overrides); + expect(await files['main.js']).not.toMatch(/\.decorators =/); + }); + + it('fails if AOT is disabled', async () => { + const overrides = { aot: false, buildOptimizer: true }; + const run = await architect.scheduleTarget(targetSpec, overrides); + await expectAsync(run.result).toBeRejectedWithError(); + await run.stop(); + }); + + it('reduces bundle size', async () => { + const noBoOverrides = { aot: true, optimization: true, vendorChunk: false }; + const boOverrides = { ...noBoOverrides, buildOptimizer: true }; + + const run = await architect.scheduleTarget(targetSpec, noBoOverrides); + const output = await run.result; + + expect(output.success).toBe(true); + + const noBoStats = await lastValueFrom( + host.stat(join(normalize(output.outputs[0].path), 'main.js')), + ); + if (!noBoStats) { + throw new Error('Main file has no stats'); + } + const noBoSize = noBoStats.size; + await run.stop(); + + const boRun = await architect.scheduleTarget(targetSpec, boOverrides); + const boOutput = await run.result; + expect(boOutput.success).toBe(true); + + const boStats = await lastValueFrom( + host.stat(join(normalize(output.outputs[0].path), 'main.js')), + ); + if (!boStats) { + throw new Error('Main file has no stats'); + } + const boSize = boStats.size; + await boRun.stop(); + + const sizeDiff = Math.round(((boSize - noBoSize) / noBoSize) * 10000) / 100; + if (sizeDiff > -1 && sizeDiff < 0) { + throw new Error( + 'Total size difference is too small, ' + + 'build optimizer does not seem to have made any optimizations.', + ); + } + + if (sizeDiff > 1) { + throw new Error( + 'Total size difference is positive, ' + 'build optimizer made the bundle bigger.', + ); + } + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/cross-origin_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/cross-origin_spec.ts new file mode 100644 index 000000000000..4054b822d797 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/cross-origin_spec.ts @@ -0,0 +1,106 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { join, normalize } from '@angular-devkit/core'; +import { lastValueFrom } from 'rxjs'; +import { createArchitect, host } from '../../../testing/test-utils'; +import { BrowserBuilderOutput } from '../index'; +import { CrossOrigin } from '../schema'; + +describe('Browser Builder crossOrigin', () => { + const targetSpec = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + + host.writeMultipleFiles({ + 'src/index.html': Buffer.from( + '\ufeff', + 'utf8', + ), + }); + }); + + afterEach(async () => host.restore().toPromise()); + + it('works with use-credentials', async () => { + const overrides = { crossOrigin: CrossOrigin.UseCredentials }; + const run = await architect.scheduleTarget(targetSpec, overrides); + const output = await run.result; + expect(output.success).toBe(true); + const fileName = join(normalize(output.outputs[0].path), 'index.html'); + const content = new TextDecoder().decode(await lastValueFrom(host.read(normalize(fileName)))); + expect(content).toBe( + `` + + `` + + `` + + `` + + `` + + ``, + ); + await run.stop(); + }); + + it('works with anonymous', async () => { + const overrides = { crossOrigin: CrossOrigin.Anonymous }; + const run = await architect.scheduleTarget(targetSpec, overrides); + const output = await run.result; + expect(output.success).toBe(true); + const fileName = join(normalize(output.outputs[0].path), 'index.html'); + const content = new TextDecoder().decode(await lastValueFrom(host.read(normalize(fileName)))); + expect(content).toBe( + `` + + `` + + `` + + `` + + `` + + `` + + ``, + ); + await run.stop(); + }); + + it('works with none', async () => { + const overrides = { crossOrigin: CrossOrigin.None }; + const run = await architect.scheduleTarget(targetSpec, overrides); + const output = await run.result; + expect(output.success).toBe(true); + const fileName = join(normalize(output.outputs[0].path), 'index.html'); + const content = new TextDecoder().decode(await lastValueFrom(host.read(normalize(fileName)))); + expect(content).toBe( + `` + + `` + + `` + + `` + + `` + + `` + + ``, + ); + await run.stop(); + }); + + it('works for lazy chunks', async () => { + host.writeMultipleFiles({ + 'src/lazy-module.ts': 'export const value = 100;', + 'src/main.ts': `import('./lazy-module');`, + }); + + const overrides = { crossOrigin: CrossOrigin.UseCredentials }; + const run = await architect.scheduleTarget(targetSpec, overrides); + const output = await run.result; + expect(output.success).toBe(true); + + const fileName = join(normalize(output.outputs[0].path), 'runtime.js'); + const content = new TextDecoder().decode(await lastValueFrom(host.read(normalize(fileName)))); + expect(content).toContain('script.crossOrigin = "use-credentials"'); + await run.stop(); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/deploy-url_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/deploy-url_spec.ts new file mode 100644 index 000000000000..fdb9494034a7 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/deploy-url_spec.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { join, normalize } from '@angular-devkit/core'; +import { lastValueFrom } from 'rxjs'; +import { createArchitect, host } from '../../../testing/test-utils'; +import { BrowserBuilderOutput } from '../index'; + +describe('Browser Builder deploy url', () => { + const targetSpec = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + afterEach(async () => host.restore().toPromise()); + + it('uses deploy url for bundles urls and runtime', async () => { + const overrides = { deployUrl: 'deployUrl/' }; + const overrides2 = { deployUrl: 'http://example.com/some/path/' }; + + // Add lazy loaded chunk to provide a usage of the deploy URL + // Webpack 5+ will not include the deploy URL in the code unless needed + host.appendToFile('src/main.ts', '\nimport("./lazy");'); + host.writeMultipleFiles({ + 'src/lazy.ts': 'export const foo = "bar";', + }); + + const run = await architect.scheduleTarget(targetSpec, overrides); + const output = await run.result; + expect(output.success).toBe(true); + expect(output.outputs[0].path).not.toBeUndefined(); + const outputPath = normalize(output.outputs[0].path); + + const fileName = join(outputPath, 'index.html'); + const runtimeFileName = join(outputPath, 'runtime.js'); + const content = new TextDecoder().decode(await lastValueFrom(host.read(normalize(fileName)))); + expect(content).toContain('deployUrl/main.js'); + const runtimeContent = new TextDecoder().decode( + await lastValueFrom(host.read(normalize(runtimeFileName))), + ); + expect(runtimeContent).toContain('deployUrl/'); + + const run2 = await architect.scheduleTarget(targetSpec, overrides2); + const output2 = await run2.result; + expect(output2.outputs[0].path).toEqual(outputPath); // These should be the same. + + const content2 = new TextDecoder().decode(await lastValueFrom(host.read(normalize(fileName)))); + expect(content2).toContain('http://example.com/some/path/main.js'); + + await run.stop(); + await run2.stop(); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/errors_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/errors_spec.ts new file mode 100644 index 000000000000..cc6f54f07017 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/errors_spec.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { logging } from '@angular-devkit/core'; +import { createArchitect, host } from '../../../testing/test-utils'; + +describe('Browser Builder errors', () => { + const targetSpec = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + afterEach(async () => host.restore().toPromise()); + + it('shows error when files are not part of the compilation', async () => { + host.replaceInFile('src/tsconfig.app.json', /,\r?\n?\s*"polyfills\.ts"/, ''); + host.replaceInFile('src/tsconfig.app.json', '"**/*.ts"', '"**/*.d.ts"'); + const logger = new logging.Logger(''); + const logs: string[] = []; + logger.subscribe((e) => logs.push(e.message)); + + const run = await architect.scheduleTarget(targetSpec, {}, { logger }); + const output = await run.result; + expect(output.success).toBe(false); + expect(logs.join()).toContain('polyfills.ts is missing from the TypeScript'); + await run.stop(); + }); + + it('shows TS syntax errors', async () => { + host.appendToFile('src/app/app.component.ts', ']]]'); + const logger = new logging.Logger(''); + const logs: string[] = []; + logger.subscribe((e) => logs.push(e.message)); + + const run = await architect.scheduleTarget(targetSpec, {}, { logger }); + const output = await run.result; + expect(output.success).toBe(false); + expect(logs.join()).toContain('Declaration or statement expected'); + await run.stop(); + }); + + it('shows static analysis errors', async () => { + host.replaceInFile('src/app/app.component.ts', `'app-root'`, `(() => 'app-root')()`); + const logger = new logging.Logger(''); + const logs: string[] = []; + logger.subscribe((e) => logs.push(e.message)); + + const run = await architect.scheduleTarget(targetSpec, { aot: true }, { logger }); + const output = await run.result; + expect(output.success).toBe(false); + + // Wait for the builder to complete + await run.stop(); + + expect(logs.join()).toContain('selector must be a string'); + }); + + it('shows missing export errors', async () => { + host.writeMultipleFiles({ + 'src/not-main.js': ` + import { missingExport } from 'rxjs'; + console.log(missingExport); + `, + }); + const overrides = { main: 'src/not-main.js' }; + const logger = new logging.Logger(''); + const logs: string[] = []; + logger.subscribe((e) => logs.push(e.message)); + + const run = await architect.scheduleTarget(targetSpec, overrides, { logger }); + const output = await run.result; + expect(output.success).toBe(false); + + // Wait for the builder to complete + await run.stop(); + + expect(logs.join()).toContain( + `export 'missingExport' (imported as 'missingExport') was not found in 'rxjs'`, + ); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/font-optimization_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/font-optimization_spec.ts new file mode 100644 index 000000000000..5791aca09ec9 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/font-optimization_spec.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { browserBuild, createArchitect, host } from '../../../testing/test-utils'; + +describe('Browser Builder font optimization', () => { + const target = { project: 'app', target: 'build' }; + const overrides = { + optimization: { + styles: false, + fonts: true, + }, + }; + + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + + host.replaceInFile( + '/src/index.html', + '', + ``, + ); + }); + + afterEach(async () => host.restore().toPromise()); + + it('works', async () => { + const { files } = await browserBuild(architect, host, target, overrides); + const html = await files['index.html']; + expect(html).not.toContain('href="https://fonts.googleapis.com/css?family=Roboto:300,400,500"'); + expect(html).toContain(`font-family: 'Roboto'`); + }); + + it('should not add woff', async () => { + const { files } = await browserBuild(architect, host, target, overrides); + const html = await files['index.html']; + expect(html).toContain(`format('woff2');`); + expect(html).not.toContain(`format('woff');`); + }); + + it('should remove comments and line breaks when styles optimization is true', async () => { + const { files } = await browserBuild(architect, host, target, { + optimization: { + styles: true, + fonts: true, + }, + }); + const html = await files['index.html']; + expect(html).not.toContain('/*'); + expect(html).toContain(';font-style:normal;'); + }); + + it('should not remove comments and line breaks when styles optimization is false', async () => { + const { files } = await browserBuild(architect, host, target, { + optimization: { + styles: false, + fonts: true, + }, + }); + + const html = await files['index.html']; + expect(html).toContain('/*'); + expect(html).toContain(' font-style: normal;\n'); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/index_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/index_spec.ts new file mode 100644 index 000000000000..8a373394f47a --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/index_spec.ts @@ -0,0 +1,256 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { join, normalize, tags, workspaces } from '@angular-devkit/core'; +import { lastValueFrom } from 'rxjs'; +import { createArchitect, host } from '../../../testing/test-utils'; +import { BrowserBuilderOutput } from '../index'; + +describe('Browser Builder index HTML processing', () => { + const targetSpec = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + afterEach(async () => host.restore().toPromise()); + + it('works with UTF-8 BOM', async () => { + host.writeMultipleFiles({ + 'src/index.html': Buffer.from( + '\ufeff', + 'utf8', + ), + }); + + const run = await architect.scheduleTarget(targetSpec); + const output = await run.result; + expect(output.success).toBe(true); + const fileName = join(normalize(output.outputs[0].path), 'index.html'); + const content = new TextDecoder().decode(await lastValueFrom(host.read(normalize(fileName)))); + expect(content).toBe( + `` + + `` + + `` + + ``, + ); + await run.stop(); + }); + + // todo: enable when utf16 is supported + xit('works with UTF16 LE BOM', async () => { + host.writeMultipleFiles({ + 'src/index.html': Buffer.from( + '\ufeff', + 'utf16le', + ), + }); + + const run = await architect.scheduleTarget(targetSpec); + const output = await run.result; + expect(output.success).toBe(true); + const fileName = join(normalize(output.outputs[0].path), 'index.html'); + const content = new TextDecoder().decode(await lastValueFrom(host.read(normalize(fileName)))); + expect(content).toBe( + `` + + `` + + `` + + `` + + ``, + ); + await run.stop(); + }); + + it('keeps escaped charaters', async () => { + host.writeMultipleFiles({ + 'src/index.html': tags.oneLine` + í + + `, + }); + + const run = await architect.scheduleTarget(targetSpec); + const output = await run.result; + expect(output.success).toBe(true); + const fileName = join(normalize(output.outputs[0].path), 'index.html'); + const content = new TextDecoder().decode(await lastValueFrom(host.read(normalize(fileName)))); + expect(content).toBe( + `í ` + + `` + + `` + + ``, + ); + await run.stop(); + }); + + it('keeps custom template charaters', async () => { + host.writeMultipleFiles({ + 'src/index.html': tags.oneLine` + <%= csrf_meta_tags %> + + `, + }); + + const run = await architect.scheduleTarget(targetSpec); + const output = await run.result; + expect(output.success).toBe(true); + const fileName = join(normalize(output.outputs[0].path), 'index.html'); + const content = new TextDecoder().decode(await lastValueFrom(host.read(normalize(fileName)))); + expect(content).toBe( + `<%= csrf_meta_tags %> ` + + `` + + `` + + ``, + ); + await run.stop(); + }); + + it('uses the input value from the index option longform', async () => { + const { workspace } = await workspaces.readWorkspace( + host.root(), + workspaces.createWorkspaceHost(host), + ); + const app = workspace.projects.get('app'); + if (!app) { + fail('Test application "app" not found.'); + + return; + } + const target = app.targets.get('build'); + if (!target) { + fail('Test application "app" target "build" not found.'); + + return; + } + if (!target.options) { + target.options = {}; + } + target.options.index = { input: 'src/index-2.html' }; + await workspaces.writeWorkspace(workspace, workspaces.createWorkspaceHost(host)); + + host.writeMultipleFiles({ + 'src/index-2.html': tags.oneLine` + <%= csrf_meta_tags %> + + `, + }); + await host.delete(join(host.root(), normalize('src/index.html'))).toPromise(); + + // Recreate architect to use update angular.json + architect = (await createArchitect(host.root())).architect; + + const run = await architect.scheduleTarget(targetSpec); + await expectAsync(run.result).toBeResolvedTo(jasmine.objectContaining({ success: true })); + + const outputIndexPath = join(host.root(), 'dist', 'index.html'); + const content = await lastValueFrom(host.read(normalize(outputIndexPath))); + expect(new TextDecoder().decode(content)).toBe( + `<%= csrf_meta_tags %> ` + + `` + + `` + + ``, + ); + await run.stop(); + }); + + it('uses the output value from the index option longform', async () => { + const { workspace } = await workspaces.readWorkspace( + host.root(), + workspaces.createWorkspaceHost(host), + ); + const app = workspace.projects.get('app'); + if (!app) { + fail('Test application "app" not found.'); + + return; + } + const target = app.targets.get('build'); + if (!target) { + fail('Test application "app" target "build" not found.'); + + return; + } + if (!target.options) { + target.options = {}; + } + target.options.index = { input: 'src/index.html', output: 'main.html' }; + await workspaces.writeWorkspace(workspace, workspaces.createWorkspaceHost(host)); + + host.writeMultipleFiles({ + 'src/index.html': tags.oneLine` + + + `, + }); + + // Recreate architect to use update angular.json + architect = (await createArchitect(host.root())).architect; + + const run = await architect.scheduleTarget(targetSpec); + await expectAsync(run.result).toBeResolvedTo(jasmine.objectContaining({ success: true })); + + const outputIndexPath = join(host.root(), 'dist', 'main.html'); + const content = await lastValueFrom(host.read(normalize(outputIndexPath))); + expect(new TextDecoder().decode(content)).toBe( + ` ` + + `` + + `` + + ``, + ); + await run.stop(); + }); + + it('creates subdirectories for output value from the index option longform', async () => { + const { workspace } = await workspaces.readWorkspace( + host.root(), + workspaces.createWorkspaceHost(host), + ); + const app = workspace.projects.get('app'); + if (!app) { + fail('Test application "app" not found.'); + + return; + } + const target = app.targets.get('build'); + if (!target) { + fail('Test application "app" target "build" not found.'); + + return; + } + if (!target.options) { + target.options = {}; + } + target.options.index = { input: 'src/index.html', output: 'extra/main.html' }; + await workspaces.writeWorkspace(workspace, workspaces.createWorkspaceHost(host)); + + host.writeMultipleFiles({ + 'src/index.html': tags.oneLine` + + + `, + }); + + // Recreate architect to use update angular.json + architect = (await createArchitect(host.root())).architect; + + const run = await architect.scheduleTarget(targetSpec); + await expectAsync(run.result).toBeResolvedTo(jasmine.objectContaining({ success: true })); + + const outputIndexPath = join(host.root(), 'dist', 'extra', 'main.html'); + const content = await lastValueFrom(host.read(normalize(outputIndexPath))); + expect(new TextDecoder().decode(content)).toBe( + ` ` + + `` + + `` + + ``, + ); + await run.stop(); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/lazy-module_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/lazy-module_spec.ts new file mode 100644 index 000000000000..a68936a65036 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/lazy-module_spec.ts @@ -0,0 +1,170 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { TestProjectHost } from '@angular-devkit/architect/testing'; +import { logging } from '@angular-devkit/core'; +import { debounceTime, take, tap } from 'rxjs'; +import { + browserBuild, + createArchitect, + host, + lazyModuleFiles, + lazyModuleFnImport, +} from '../../../testing/test-utils'; + +describe('Browser Builder lazy modules', () => { + const target = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + afterEach(async () => host.restore().toPromise()); + + function addLazyLoadedModulesInTsConfig( + host: TestProjectHost, + lazyModuleFiles: Record, + ) { + const files = [...Object.keys(lazyModuleFiles), 'main.ts'] + .map((f) => '"' + f.replace('src/', '') + '"') + .join(', '); + + host.replaceInFile('src/tsconfig.app.json', '"main.ts"', `${files}`); + } + + function hasMissingModuleError(logs: string): boolean { + // TS type error when using import(). + return ( + logs.includes('Cannot find module') || + // Webpack error when using import() on a rebuild. + // There is no TS error because the type checker is forked on rebuilds. + logs.includes('Module not found') + ); + } + + describe(`Load children syntax`, () => { + it('supports lazy bundle for lazy routes with JIT', async () => { + host.writeMultipleFiles(lazyModuleFiles); + host.writeMultipleFiles(lazyModuleFnImport); + + const { files } = await browserBuild(architect, host, target); + expect('src_app_lazy_lazy_module_ts.js' in files).toBe(true); + }); + + it('supports lazy bundle for lazy routes with AOT', async () => { + host.writeMultipleFiles(lazyModuleFiles); + host.writeMultipleFiles(lazyModuleFnImport); + addLazyLoadedModulesInTsConfig(host, lazyModuleFiles); + + const { files } = await browserBuild(architect, host, target, { aot: true }); + const data = await files['src_app_lazy_lazy_module_ts.js']; + expect(data).toContain('static ɵmod'); + }); + }); + + // Errors for missing lazy routes are only supported with function syntax. + // `ngProgram.listLazyRoutes` will ignore invalid lazy routes in the route map. + describe(`Load children errors with function syntax`, () => { + it('should show error when lazy route is invalid', async () => { + host.writeMultipleFiles(lazyModuleFiles); + host.writeMultipleFiles(lazyModuleFnImport); + host.replaceInFile('src/app/app.module.ts', 'lazy.module', 'invalid.module'); + + const logger = new logging.Logger(''); + const logs: string[] = []; + logger.subscribe((e) => logs.push(e.message)); + + const run = await architect.scheduleTarget(target, {}, { logger }); + const output = await run.result; + expect(output.success).toBe(false); + expect(hasMissingModuleError(logs.join())).toBeTrue(); + await run.stop(); + }); + + it('should show error when lazy route is invalid on watch mode AOT', async () => { + host.writeMultipleFiles(lazyModuleFiles); + host.writeMultipleFiles(lazyModuleFnImport); + + let buildNumber = 0; + + const overrides = { watch: true, aot: true }; + const run = await architect.scheduleTarget(target, overrides); + await run.output + .pipe( + debounceTime(1000), + tap((buildEvent) => { + buildNumber++; + switch (buildNumber) { + case 1: + expect(buildEvent.success).toBe(true); + host.replaceInFile('src/app/app.module.ts', 'lazy.module', 'invalid.module'); + break; + case 2: + expect(buildEvent.success).toBe(false); + break; + } + }), + take(2), + ) + .toPromise(); + await run.stop(); + }); + }); + + it(`supports lazy bundle for import() calls`, async () => { + host.writeMultipleFiles({ + 'src/lazy-module.ts': 'export const value = 42;', + 'src/main.ts': `import('./lazy-module');`, + }); + + const { files } = await browserBuild(architect, host, target); + expect(files['src_lazy-module_ts.js']).toBeDefined(); + }); + + it(`supports lazy bundle for dynamic import() calls`, async () => { + host.writeMultipleFiles({ + 'src/lazy-module.ts': 'export const value = 42;', + 'src/main.ts': ` + const lazyFileName = 'module'; + import('./lazy-' + lazyFileName); + `, + }); + host.replaceInFile('src/tsconfig.app.json', '"main.ts"', `"main.ts","lazy-module.ts"`); + + const { files } = await browserBuild(architect, host, target); + expect(files['src_lazy-module_ts.js']).toBeDefined(); + }); + + it(`supports making a common bundle for shared lazy modules`, async () => { + host.writeMultipleFiles({ + 'src/one.ts': `import * as http from '@angular/common/http'; console.log(http);`, + 'src/two.ts': `import * as http from '@angular/common/http'; console.log(http);`, + 'src/main.ts': `import('./one'); import('./two');`, + }); + + const { files } = await browserBuild(architect, host, target); + expect(files['src_one_ts.js']).toBeDefined(); + expect(files['src_two_ts.js']).toBeDefined(); + expect(Object.keys(files)).toContain(jasmine.stringContaining('_angular_common_')); + }); + + it(`supports disabling the common bundle`, async () => { + host.writeMultipleFiles({ + 'src/one.ts': `import * as http from '@angular/common/http'; console.log(http);`, + 'src/two.ts': `import * as http from '@angular/common/http'; console.log(http);`, + 'src/main.ts': `import('./one'); import('./two');`, + }); + + const { files } = await browserBuild(architect, host, target, { commonChunk: false }); + expect(files['src_one_ts.js']).toBeDefined(); + expect(files['src_two_ts.js']).toBeDefined(); + expect(files['default-node_modules_angular_common_fesm2022_http_mjs.js']).toBeUndefined(); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/optimization-level_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/optimization-level_spec.ts new file mode 100644 index 000000000000..a75cc8b7aeb4 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/optimization-level_spec.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { browserBuild, createArchitect, host } from '../../../testing/test-utils'; + +describe('Browser Builder optimization level', () => { + const target = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + afterEach(async () => host.restore().toPromise()); + + it('works', async () => { + const overrides = { optimization: true }; + + const { files } = await browserBuild(architect, host, target, overrides); + expect(await files['main.js']).not.toContain('AppComponent'); + }); + + it('supports styles only optimizations', async () => { + const overrides = { + optimization: { + styles: true, + scripts: false, + }, + aot: true, + styles: ['src/styles.css'], + }; + + host.appendToFile('src/main.ts', '/** js comment should not be dropped */'); + host.appendToFile('src/app/app.component.css', 'div { color: white }'); + host.writeMultipleFiles({ + 'src/styles.css': `div { color: white }`, + }); + + const { files } = await browserBuild(architect, host, target, overrides); + expect(await files['main.js']).toContain('js comment should not be dropped'); + expect(await files['main.js']).toContain('color:#fff'); + expect(await files['styles.css']).toContain('color:#fff'); + }); + + it('supports scripts only optimizations', async () => { + const overrides = { + optimization: { + styles: false, + scripts: true, + }, + aot: true, + styles: ['src/styles.css'], + }; + + host.appendToFile('src/main.ts', '/** js comment should be dropped */'); + host.appendToFile('src/app/app.component.css', 'div { color: white }'); + host.writeMultipleFiles({ + 'src/styles.css': `div { color: white }`, + }); + + const { files } = await browserBuild(architect, host, target, overrides); + expect(await files['main.js']).not.toContain('js comment should be dropped'); + expect(await files['main.js']).toContain('color: white'); + expect(await files['styles.css']).toContain('color: white'); + }); + + it('outputs ASCII only content', async () => { + const overrides = { aot: true, optimization: true }; + + host.writeMultipleFiles({ + 'src/app/app.component.html': `

€€€

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

HTML_REBUILD_STRING

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

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

Hello {{ title }}!

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

{{title}}

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

Login page

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

Login page

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

Login page

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

Login page

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

hello world

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

testingTESTING123

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

Hello i18n!

')) - .then(() => ng('build', '--aot', '--i18n-file', 'src/locale/messages.fr.xlf', '--i18n-format', - 'xlf', '--locale', 'fr')) - .then(() => expectFileToMatch('dist/main.bundle.js', /Bonjour i18n!/)) - .then(() => ng('build', '--aot')) - .then(() => expectToFail(() => expectFileToMatch('dist/main.bundle.js', /Bonjour i18n!/))) - .then(() => expectFileToMatch('dist/main.bundle.js', /Hello i18n!/)); -} diff --git a/tests/e2e/tests/build/aot.ts b/tests/e2e/tests/build/aot.ts deleted file mode 100644 index 0cfe62bc7364..000000000000 --- a/tests/e2e/tests/build/aot.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {ng} from '../../utils/process'; -import {expectFileToMatch} from '../../utils/fs'; - -export default function() { - return ng('build', '--aot') - .then(() => expectFileToMatch('dist/main.bundle.js', - /bootstrapModuleFactory.*\/\* AppModuleNgFactory \*\//)); -} diff --git a/tests/e2e/tests/build/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/base-href.ts b/tests/e2e/tests/build/base-href.ts deleted file mode 100644 index c8ff910a1a20..000000000000 --- a/tests/e2e/tests/build/base-href.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {ng} from '../../utils/process'; -import {expectFileToMatch} from '../../utils/fs'; - - -export default function() { - return ng('build', '--base-href', '/myUrl') - .then(() => expectFileToMatch('dist/index.html', //)); -} diff --git a/tests/e2e/tests/build/bundle-budgets.ts b/tests/e2e/tests/build/bundle-budgets.ts new file mode 100644 index 000000000000..1401d8d3c6e3 --- /dev/null +++ b/tests/e2e/tests/build/bundle-budgets.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ +import assert from 'node:assert/strict'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { expectToFail } from '../../utils/utils'; + +export default async function () { + // Error + await updateJsonFile('angular.json', (json) => { + json.projects['test-project'].architect.build.configurations.production.budgets = [ + { type: 'all', maximumError: '100b' }, + ]; + }); + + const { message: errorMessage } = await expectToFail(() => ng('build')); + assert.match(errorMessage, /Error.+budget/i, 'Budget error: all, max error.'); + + // Warning + await updateJsonFile('angular.json', (json) => { + json.projects['test-project'].architect.build.configurations.production.budgets = [ + { type: 'all', minimumWarning: '100mb' }, + ]; + }); + + const { stderr } = await ng('build'); + assert.match(stderr, /Warning.+budget/i, 'Budget warning: all, min warning'); + + // Pass + await updateJsonFile('angular.json', (json) => { + json.projects['test-project'].architect.build.configurations.production.budgets = [ + { type: 'allScript', maximumError: '100mb' }, + ]; + }); + + const { stderr: stderr2 } = await ng('build'); + assert.doesNotMatch(stderr2, /(Warning|Error)/i, 'BIG max for all, should not error'); +} diff --git a/tests/e2e/tests/build/chunk-hash.ts b/tests/e2e/tests/build/chunk-hash.ts deleted file mode 100644 index 9e79a7e52b50..000000000000 --- a/tests/e2e/tests/build/chunk-hash.ts +++ /dev/null @@ -1,56 +0,0 @@ -import {oneLine} from 'common-tags'; -import * as fs from 'fs'; - -import {ng} from '../../utils/process'; -import {writeFile} from '../../utils/fs'; -import {addImportToModule} from '../../utils/ast'; - - -export default function() { - const oldHashes: {[module: string]: string} = {}; - const newHashes: {[module: string]: string} = {}; - // First, collect the hashes. - return Promise.resolve() - .then(() => ng('generate', 'module', 'lazy', '--routing')) - .then(() => addImportToModule('src/app/app.module.ts', oneLine` - RouterModule.forRoot([{ path: "lazy", loadChildren: "./lazy/lazy.module#LazyModule" }]) - `, '@angular/router')) - .then(() => ng('build', '--prod')) - .then(() => { - fs.readdirSync('./dist') - .forEach(name => { - if (!name.match(/(main|inline|styles|\d+)\.[a-z0-9]+\.bundle\.(js|css)/)) { - return; - } - - const [module, hash] = name.split('.'); - oldHashes[module] = hash; - }); - }) - .then(() => writeFile('src/app/app.component.css', 'h1 { margin: 5px; }')) - .then(() => writeFile('src/styles.css', 'body { background: red; }')) - .then(() => ng('build', '--prod')) - .then(() => { - fs.readdirSync('./dist') - .forEach(name => { - if (!name.match(/(main|inline|styles|\d+)\.[a-z0-9]+\.bundle\.(js|css)/)) { - return; - } - - const [module, hash] = name.split('.'); - newHashes[module] = hash; - }); - }) - .then(() => { - console.log(' Validating hashes...'); - console.log(` Old hashes: ${JSON.stringify(oldHashes)}`); - console.log(` New hashes: ${JSON.stringify(newHashes)}`); - - Object.keys(oldHashes) - .forEach(module => { - if (oldHashes[module] == newHashes[module]) { - throw new Error(`Module "${module}" did not change hash (${oldHashes[module]})...`); - } - }); - }); -} 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/dev-build.ts b/tests/e2e/tests/build/dev-build.ts deleted file mode 100644 index f6f96df9deb1..000000000000 --- a/tests/e2e/tests/build/dev-build.ts +++ /dev/null @@ -1,10 +0,0 @@ -import {ng} from '../../utils/process'; -import {expectFileToMatch} from '../../utils/fs'; -import {expectGitToBeClean} from '../../utils/git'; - - -export default function() { - return ng('build', '--env=dev') - .then(() => expectFileToMatch('dist/index.html', 'main.bundle.js')) - .then(() => expectGitToBeClean()); -} diff --git a/tests/e2e/tests/build/disk-cache-purge.ts b/tests/e2e/tests/build/disk-cache-purge.ts new file mode 100644 index 000000000000..52edd4845038 --- /dev/null +++ b/tests/e2e/tests/build/disk-cache-purge.ts @@ -0,0 +1,30 @@ +import { join } from 'node:path'; +import { createDir, expectFileNotToExist, expectFileToExist, writeFile } from '../../utils/fs'; +import { silentNg } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; + +export default async function () { + const cachePath = '.angular/cache'; + const staleCachePath = join(cachePath, 'v1.0.0'); + + // No need to include all applications code to verify disk cache existence. + await writeFile('src/main.ts', 'console.log(1);'); + + // Enable cache for all environments + await updateJsonFile('angular.json', (config) => { + config.cli ??= {}; + config.cli.cache = { + environment: 'all', + enabled: true, + path: cachePath, + }; + }); + + // Create a dummy stale disk cache directory. + await createDir(staleCachePath); + await expectFileToExist(staleCachePath); + + await silentNg('build'); + await expectFileToExist(cachePath); + await expectFileNotToExist(staleCachePath); +} diff --git a/tests/e2e/tests/build/disk-cache.ts b/tests/e2e/tests/build/disk-cache.ts new file mode 100644 index 000000000000..1873905646ca --- /dev/null +++ b/tests/e2e/tests/build/disk-cache.ts @@ -0,0 +1,56 @@ +import { expectFileNotToExist, expectFileToExist, rimraf, writeFile } from '../../utils/fs'; +import { silentNg } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; + +const defaultCachePath = '.angular/cache'; +const overriddenCachePath = '.cache/angular-cli'; + +export default async function () { + const originalCIValue = process.env['CI']; + + // No need to include all applications code to verify disk cache existence. + await writeFile('src/main.ts', 'console.log(1);'); + + try { + // Should be enabled by default. + process.env['CI'] = '0'; + await configureAndRunTest(); + + // Should not write cache when it's disabled + await configureAndRunTest({ enabled: false }); + await expectFileNotToExist(defaultCachePath); + + // Should not write cache by default when in CI. + process.env['CI'] = '1'; + await configureAndRunTest(); + await expectFileNotToExist(defaultCachePath); + + // Should write cache when it's enabled and 'environment' is set to 'all' or 'ci'. + await configureAndRunTest({ environment: 'all' }); + await expectFileToExist(defaultCachePath); + + // Should write cache to custom path when configured. + await configureAndRunTest({ environment: 'ci', path: overriddenCachePath }); + await expectFileNotToExist(defaultCachePath); + await expectFileToExist(overriddenCachePath); + } finally { + process.env['CI'] = originalCIValue; + } +} + +async function configureAndRunTest(cacheOptions?: { + environment?: 'ci' | 'local' | 'all'; + enabled?: boolean; + path?: string; +}): Promise { + await Promise.all([ + rimraf(overriddenCachePath), + rimraf(defaultCachePath), + updateJsonFile('angular.json', (config) => { + config.cli ??= {}; + config.cli.cache = cacheOptions; + }), + ]); + + await silentNg('build'); +} diff --git a/tests/e2e/tests/build/environment.ts b/tests/e2e/tests/build/environment.ts deleted file mode 100644 index 734461a2e0a5..000000000000 --- a/tests/e2e/tests/build/environment.ts +++ /dev/null @@ -1,18 +0,0 @@ -import {ng} from '../../utils/process'; -import {expectFileToMatch} from '../../utils/fs'; -import {expectGitToBeClean} from '../../utils/git'; -import {expectToFail} from '../../utils/utils'; - - -export default function() { - // Try a prod build. - return ng('build', '--env=prod') - .then(() => expectFileToMatch('dist/main.bundle.js', 'production: true')) - .then(() => expectGitToBeClean()) - - // Build fails on invalid build target - .then(() => expectToFail(() => ng('build', '--target=potato'))) - - // This is a valid target. - .then(() => ng('build', '--target=production')); -} diff --git a/tests/e2e/tests/build/extract-licenses.ts b/tests/e2e/tests/build/extract-licenses.ts 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/fail-build.ts b/tests/e2e/tests/build/fail-build.ts deleted file mode 100644 index 3bbc8eff3158..000000000000 --- a/tests/e2e/tests/build/fail-build.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {ng} from '../../utils/process'; -import {expectToFail} from '../../utils/utils'; -import {deleteFile} from '../../utils/fs'; - -export default function() { - return deleteFile('src/app/app.component.ts') - // This is supposed to fail since there's a missing file - .then(() => expectToFail(() => ng('build'))); -} diff --git a/tests/e2e/tests/build/filename.ts b/tests/e2e/tests/build/filename.ts deleted file mode 100644 index a9e7258959e1..000000000000 --- a/tests/e2e/tests/build/filename.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {ng} from '../../utils/process'; -import {expectFileToExist} from '../../utils/fs'; -import {updateJsonFile} from '../../utils/project'; -import {copyFile} from '../../utils/fs'; - - -export default function() { - return Promise.resolve() - .then(() => copyFile('src/index.html', 'src/config-index.html')) - .then(() => updateJsonFile('angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['outDir'] = 'config-build-output'; - app['index'] = 'config-index.html'; - })) - .then(() => ng('build')) - .then(() => expectFileToExist('./config-build-output/config-index.html')); -} diff --git a/tests/e2e/tests/build/incremental-watch.ts b/tests/e2e/tests/build/incremental-watch.ts new file mode 100644 index 000000000000..68f281c8bab5 --- /dev/null +++ b/tests/e2e/tests/build/incremental-watch.ts @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import { readdir } from 'node:fs/promises'; +import { setTimeout } from 'node:timers/promises'; +import { getGlobalVariable } from '../../utils/env'; +import { appendToFile, readFile, writeFile } from '../../utils/fs'; +import { execAndWaitForOutputToMatch, waitForAnyProcessOutputToMatch } from '../../utils/process'; + +const buildReadyRegEx = /Application bundle generation complete\./; + +async function getOutputChunks( + dir: string, + predicate: (chunks: string[]) => boolean, + timeout = 10_000, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeout) { + try { + const chunks = (await readdir(dir)).filter((file) => file.endsWith('.js')); + if (predicate(chunks)) { + return chunks; + } + } catch (err: any) { + if (err?.code !== 'ENOENT') { + throw err; + } + } + await setTimeout(50); + } + + const chunks = (await readdir(dir)).filter((file) => file.endsWith('.js')); + assert(predicate(chunks), `Condition not met for chunks in ${dir}: ${JSON.stringify(chunks)}`); + + return chunks; +} + +export default async function () { + const usingApplicationBuilder = getGlobalVariable('argv')['esbuild']; + assert( + usingApplicationBuilder, + 'Incremental watch E2E test should not be executed with Webpack.', + ); + + // Perform an initial build in watch mode + await execAndWaitForOutputToMatch( + 'ng', + ['build', '--watch', '--configuration=development'], + buildReadyRegEx, + ); + const initialOutputChunks = await getOutputChunks( + 'dist/test-project/browser', + (chunks) => chunks.length > 0, + ); + + const originalMain = await readFile('src/main.ts'); + + // Add a dynamic import to create an additional output chunk + await Promise.all([ + waitForAnyProcessOutputToMatch(buildReadyRegEx), + writeFile( + 'src/a.ts', + ` + export function sayHi() { + console.log('hi'); + } + `, + ), + appendToFile('src/main.ts', `\nimport('./a').then((m) => m.sayHi());`), + ]); + const intermediateOutputChunks = await getOutputChunks( + 'dist/test-project/browser', + (chunks) => chunks.length > initialOutputChunks.length, + ); + assert( + initialOutputChunks.length < intermediateOutputChunks.length, + 'Additional chunks should be present', + ); + + // Remove usage of dynamic import which should remove the additional output chunk + await Promise.all([ + waitForAnyProcessOutputToMatch(buildReadyRegEx), + writeFile('src/main.ts', originalMain), + ]); + const finalOutputChunks = await getOutputChunks( + 'dist/test-project/browser', + (chunks) => chunks.length === initialOutputChunks.length, + ); + assert.equal( + initialOutputChunks.length, + finalOutputChunks.length, + 'Final chunk count should be equal to initial chunk count.', + ); +} diff --git a/tests/e2e/tests/build/jit-ngmodule.ts b/tests/e2e/tests/build/jit-ngmodule.ts new file mode 100644 index 000000000000..aa6b3fda86bb --- /dev/null +++ b/tests/e2e/tests/build/jit-ngmodule.ts @@ -0,0 +1,48 @@ +import { getGlobalVariable } from '../../utils/env'; +import { ng } from '../../utils/process'; +import { updateJsonFile, useCIDefaults } from '../../utils/project'; +import { executeBrowserTest } from '../../utils/puppeteer'; + +export default async function () { + await ng('generate', 'app', 'test-project-two', '--no-standalone', '--skip-install'); + await useCIDefaults('test-project-two'); + + // Make prod use JIT. + + const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; + // Setup webpack builder if esbuild is not requested on the commandline + await updateJsonFile('angular.json', (json) => { + const build = json['projects']['test-project-two']['architect']['build']; + if (useWebpackBuilder) { + build.builder = '@angular-devkit/build-angular:browser'; + build.options = { + ...build.options, + main: build.options.browser, + browser: undefined, + buildOptimizer: false, + outputPath: 'dist/test-project-two', + index: 'src/index.html', + }; + + build.configurations.development = { + ...build.configurations.development, + vendorChunk: true, + namedChunks: true, + }; + } + + // Remove bundle budgets due to the increased size from JIT + build.configurations.production = { + ...build.configurations.production, + budgets: undefined, + }; + + build.options.aot = false; + + const serve = json['projects']['test-project-two']['architect']['serve']; + serve.builder = '@angular-devkit/build-angular:dev-server'; + }); + // Test it works + await executeBrowserTest({ project: 'test-project-two', configuration: 'production' }); + await executeBrowserTest({ project: 'test-project-two', configuration: 'development' }); +} diff --git a/tests/e2e/tests/build/jit-prod.ts b/tests/e2e/tests/build/jit-prod.ts new file mode 100644 index 000000000000..b2dc9d0bdddc --- /dev/null +++ b/tests/e2e/tests/build/jit-prod.ts @@ -0,0 +1,22 @@ +import { getGlobalVariable } from '../../utils/env'; +import { updateJsonFile } from '../../utils/project'; +import { executeBrowserTest } from '../../utils/puppeteer'; + +export default async function () { + // Make prod use JIT. + await updateJsonFile('angular.json', (configJson) => { + const appArchitect = configJson.projects['test-project'].architect; + appArchitect.build.configurations['production'].aot = false; + + // JIT applications have significantly larger sizes + appArchitect.build.configurations['production'].budgets = []; + + if (!getGlobalVariable('argv')['esbuild']) { + // The build optimizer option does not exist with the application build system + appArchitect.build.configurations['production'].buildOptimizer = false; + } + }); + + // Test it works + await executeBrowserTest({ configuration: 'production' }); +} diff --git a/tests/e2e/tests/build/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/no-implicit-any.ts b/tests/e2e/tests/build/no-implicit-any.ts deleted file mode 100644 index 08927a41467e..000000000000 --- a/tests/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/e2e/tests/build/output-dir.ts b/tests/e2e/tests/build/output-dir.ts index 0e34c8450fb9..0a93ebae67b4 100644 --- a/tests/e2e/tests/build/output-dir.ts +++ b/tests/e2e/tests/build/output-dir.ts @@ -1,21 +1,29 @@ -import {ng} from '../../utils/process'; -import {expectFileToExist} from '../../utils/fs'; -import {expectToFail} from '../../utils/utils'; -import {expectGitToBeClean} from '../../utils/git'; -import {updateJsonFile} from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; +import { expectFileToExist } from '../../utils/fs'; +import { expectGitToBeClean } from '../../utils/git'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { expectToFail } from '../../utils/utils'; +export default function () { + // TODO(architect): Delete this test. It is now in devkit/build-angular. -export default function() { - return ng('build', '-o', './build-output') - .then(() => expectFileToExist('./build-output/index.html')) - .then(() => expectFileToExist('./build-output/main.bundle.js')) + 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-cli.json', configJson => { - const app = configJson['apps'][0]; - app['outDir'] = 'config-build-output'; - })) - .then(() => ng('build')) - .then(() => expectFileToExist('./config-build-output/index.html')) - .then(() => expectFileToExist('./config-build-output/main.bundle.js')) + .then(() => + updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + appArchitect.build.options.outputPath = 'config-build-output'; + }), + ) + .then(() => ng('build', '--configuration=development')) + .then(() => + expectFileToExist(`./config-build-output/${usingWebpack ? '' : 'browser/'}index.html`), + ) + .then(() => expectFileToExist(`./config-build-output/${usingWebpack ? '' : 'browser/'}main.js`)) .then(() => expectToFail(expectGitToBeClean)); } diff --git a/tests/e2e/tests/build/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 index 0e9074a24dc0..dee45876e379 100644 --- a/tests/e2e/tests/build/prod-build.ts +++ b/tests/e2e/tests/build/prod-build.ts @@ -1,42 +1,48 @@ -import {join} from 'path'; -import {isMobileTest} from '../../utils/utils'; -import {expectFileToExist, expectFileToMatch} from '../../utils/fs'; -import {ng} from '../../utils/process'; -import {expectGitToBeClean} from '../../utils/git'; - - - -function mobileOnlyChecks() { - if (!isMobileTest()) { - return; - } - - // Check for mobile-specific features in prod builds. - return Promise.resolve() - // Service Worker - .then(() => expectFileToExist('dist/sw.js')) - .then(() => expectFileToMatch('dist/index.html', /sw-install\.[0-9a-f]{20}\.bundle\.js/)) - - // App Manifest - .then(() => expectFileToExist('dist/manifest.webapp')) - .then(() => expectFileToMatch('dist/index.html', - '')) - - // Icons folder - .then(() => expectFileToExist('dist/icons')); +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/); + } -export default function() { - // Can't use the `ng` helper because somewhere the environment gets - // stuck to the first build done - return ng('build', '--prod') - .then(() => expectFileToExist(join(process.cwd(), 'dist'))) - // Check for cache busting hash script src - .then(() => expectFileToMatch('dist/index.html', /main\.[0-9a-f]{20}\.bundle\.js/)) - .then(() => expectFileToMatch('dist/index.html', /styles\.[0-9a-f]{20}\.bundle\.css/)) + 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]; - // Check that the process didn't change local files. - .then(() => expectGitToBeClean()) - .then(() => mobileOnlyChecks()); + // 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-array.ts b/tests/e2e/tests/build/scripts-array.ts deleted file mode 100644 index 995a75381720..000000000000 --- a/tests/e2e/tests/build/scripts-array.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { - writeMultipleFiles, - expectFileToMatch -} from '../../utils/fs'; -import { ng } from '../../utils/process'; -import { updateJsonFile } from '../../utils/project'; -import { oneLineTrim } from 'common-tags'; - -export default function () { - return writeMultipleFiles({ - 'src/string-script.js': 'console.log(\'string-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\');', - 'src/common-entry-script.js': 'console.log(\'common-entry-script\');', - 'src/common-entry-style.css': '.common-entry-style { color: red }', - }) - .then(() => updateJsonFile('angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['scripts'] = [ - 'string-script.js', - { input: 'input-script.js' }, - { input: 'lazy-script.js', lazy: true }, - { input: 'pre-rename-script.js', output: 'renamed-script' }, - { input: 'pre-rename-lazy-script.js', output: 'renamed-lazy-script', lazy: true }, - { input: 'common-entry-script.js', output: 'common-entry' } - ]; - app['styles'] = [{ input: 'common-entry-style.css', output: 'common-entry' }]; - })) - .then(() => ng('build')) - // files were created successfully - .then(() => expectFileToMatch('dist/scripts.bundle.js', 'string-script')) - .then(() => expectFileToMatch('dist/scripts.bundle.js', 'input-script')) - .then(() => expectFileToMatch('dist/lazy-script.bundle.js', 'lazy-script')) - .then(() => expectFileToMatch('dist/renamed-script.bundle.js', 'pre-rename-script')) - .then(() => expectFileToMatch('dist/renamed-lazy-script.bundle.js', 'pre-rename-lazy-script')) - .then(() => expectFileToMatch('dist/common-entry.bundle.js', 'common-entry-script')) - .then(() => expectFileToMatch('dist/common-entry.bundle.css', '.common-entry-style')) - // index.html lists the right bundles - .then(() => expectFileToMatch('dist/index.html', oneLineTrim` - - `)) - .then(() => expectFileToMatch('dist/index.html', oneLineTrim` - - - - - - - `)); -} diff --git a/tests/e2e/tests/build/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 index 7029ff872f24..2e153e637f30 100644 --- a/tests/e2e/tests/build/sourcemap.ts +++ b/tests/e2e/tests/build/sourcemap.ts @@ -1,11 +1,54 @@ -import {ng} from '../../utils/process'; -import {expectFileToExist} from '../../utils/fs'; -import {expectToFail} from '../../utils/utils'; +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import { getGlobalVariable } from '../../utils/env'; +import { expectFileToExist } from '../../utils/fs'; +import { ng } from '../../utils/process'; +export default async function () { + const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; -export default function() { - return ng('build') - .then(() => expectFileToExist('dist/main.bundle.map')) - .then(() => ng('build', '--no-sourcemap')) - .then(() => expectToFail(() => expectFileToExist('dist/main.bundle.map'))); + // 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/css.ts b/tests/e2e/tests/build/styles/css.ts deleted file mode 100644 index f1abac5b37f0..000000000000 --- a/tests/e2e/tests/build/styles/css.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { - writeMultipleFiles, - expectFileToMatch, -} from '../../../utils/fs'; -import { ng } from '../../../utils/process'; -import { stripIndents } from 'common-tags'; - -export default function () { - return writeMultipleFiles({ - 'src/styles.css': stripIndents` - @import './imported-styles.css'; - body { background-color: blue; } - `, - 'src/imported-styles.css': stripIndents` - p { background-color: red; } - `, - 'src/app/app.component.css': stripIndents` - .outer { - .inner { - background: #fff; - } - } - `}) - .then(() => ng('build')) - .then(() => expectFileToMatch('dist/styles.bundle.css', - /body\s*{\s*background-color: blue;\s*}/)) - .then(() => expectFileToMatch('dist/styles.bundle.css', - /p\s*{\s*background-color: red;\s*}/)) - .then(() => expectFileToMatch('dist/main.bundle.js', /.outer.*.inner.*background:\s*#[fF]+/)); -} 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 index edb197b9997a..c5d58d2d3b08 100644 --- a/tests/e2e/tests/build/styles/less.ts +++ b/tests/e2e/tests/build/styles/less.ts @@ -2,39 +2,59 @@ import { writeMultipleFiles, deleteFile, expectFileToMatch, - replaceInFile + replaceInFile, } from '../../../utils/fs'; +import { expectToFail } from '../../../utils/utils'; import { ng } from '../../../utils/process'; -import { stripIndents } from 'common-tags'; import { updateJsonFile } from '../../../utils/project'; export default function () { + // TODO(architect): Delete this test. It is now in devkit/build-angular. + return writeMultipleFiles({ - 'src/styles.less': stripIndents` + 'src/styles.less': ` @import './imported-styles.less'; body { background-color: blue; } `, - 'src/imported-styles.less': stripIndents` - p { background-color: red; } - `, - 'src/app/app.component.less': stripIndents` + 'src/imported-styles.less': 'p { background-color: red; }', + 'src/app/app.less': ` .outer { .inner { background: #fff; } } - `}) - .then(() => deleteFile('src/app/app.component.css')) - .then(() => updateJsonFile('angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['styles'] = ['styles.less']; - })) - .then(() => replaceInFile('src/app/app.component.ts', - './app.component.css', './app.component.less')) - .then(() => ng('build')) - .then(() => expectFileToMatch('dist/styles.bundle.css', - /body\s*{\s*background-color: blue;\s*}/)) - .then(() => expectFileToMatch('dist/styles.bundle.css', - /p\s*{\s*background-color: red;\s*}/)) - .then(() => expectFileToMatch('dist/main.bundle.js', /.outer.*.inner.*background:\s*#[fF]+/)); + `, + }) + .then(() => deleteFile('src/app/app.css')) + .then(() => + updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + appArchitect.build.options.styles = [{ input: 'src/styles.less' }]; + }), + ) + .then(() => replaceInFile('src/app/app.ts', './app.css', './app.less')) + .then(() => ng('build', '--source-map', '--configuration=development')) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/styles.css', + /body\s*{\s*background-color: blue;\s*}/, + ), + ) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/styles.css', + /p\s*{\s*background-color: red;\s*}/, + ), + ) + .then(() => + expectToFail(() => + expectFileToMatch('dist/test-project/browser/styles.css', '"mappings":""'), + ), + ) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/main.js', + /.outer.*.inner.*background:\s*#[fF]+/, + ), + ); } diff --git a/tests/e2e/tests/build/styles/loaders.ts b/tests/e2e/tests/build/styles/loaders.ts index 9a2121c8f7dd..cbb602ece07b 100644 --- a/tests/e2e/tests/build/styles/loaders.ts +++ b/tests/e2e/tests/build/styles/loaders.ts @@ -2,38 +2,42 @@ import { writeMultipleFiles, deleteFile, expectFileToMatch, - replaceInFile + replaceInFile, } from '../../../utils/fs'; import { ng } from '../../../utils/process'; -import { stripIndents } from 'common-tags'; import { updateJsonFile } from '../../../utils/project'; import { expectToFail } from '../../../utils/utils'; -export default function () { - return writeMultipleFiles({ - 'src/styles.scss': stripIndents` +export default async function () { + await writeMultipleFiles({ + 'src/styles.scss': ` @import './imported-styles.scss'; body { background-color: blue; } `, - 'src/imported-styles.scss': stripIndents` - p { background-color: red; } - `, - 'src/app/app.component.scss': stripIndents` - .outer { - .inner { - background: #fff; - } + 'src/imported-styles.scss': 'p { background-color: red; }', + 'src/app/app.scss': ` + .outer { + .inner { + background: #fff; } - `}) - .then(() => deleteFile('src/app/app.component.css')) - .then(() => updateJsonFile('angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['styles'] = ['styles.scss']; - })) - .then(() => replaceInFile('src/app/app.component.ts', - './app.component.css', './app.component.scss')) - .then(() => ng('build')) - .then(() => expectToFail(() => expectFileToMatch('dist/styles.bundle.css', /exports/))) - .then(() => expectToFail(() => expectFileToMatch('dist/main.bundle.js', - /".*module\.exports.*\.outer.*background:/))); + } + `, + }); + + await deleteFile('src/app/app.css'); + await updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + appArchitect.build.options.styles = [{ input: 'src/styles.scss' }]; + }); + await replaceInFile('src/app/app.ts', './app.css', './app.scss'); + + await ng('build', '--configuration=development'); + + await expectToFail(() => expectFileToMatch('dist/test-project/browser/styles.css', /exports/)); + await expectToFail(() => + expectFileToMatch( + 'dist/test-project/browser/main.js', + /".*module\.exports.*\.outer.*background:/, + ), + ); } diff --git a/tests/e2e/tests/build/styles/postcss.ts b/tests/e2e/tests/build/styles/postcss.ts deleted file mode 100644 index 809ce7919f71..000000000000 --- a/tests/e2e/tests/build/styles/postcss.ts +++ /dev/null @@ -1,25 +0,0 @@ -import * as glob from 'glob'; -import { writeFile, expectFileToMatch } from '../../../utils/fs'; -import { ng } from '../../../utils/process'; -import { stripIndents } from 'common-tags'; - -export default function () { - return writeFile('src/styles.css', stripIndents` - /* normal-comment */ - /*! important-comment */ - div { flex: 1 } - `) - // uses autoprefixer plugin for all builds - .then(() => ng('build')) - .then(() => expectFileToMatch('dist/styles.bundle.css', stripIndents` - /* normal-comment */ - /*! important-comment */ - div { -webkit-box-flex: 1; -ms-flex: 1; flex: 1 } - `)) - // uses postcss-discard-comments plugin for prod - .then(() => ng('build', '--prod')) - .then(() => glob.sync('dist/styles.*.bundle.css').find(file => !!file)) - .then((stylesBundle) => expectFileToMatch(stylesBundle, stripIndents` - /*! important-comment */div{-webkit-box-flex:1;-ms-flex:1;flex:1} - `)); -} diff --git a/tests/e2e/tests/build/styles/sass-pkg-importer.ts b/tests/e2e/tests/build/styles/sass-pkg-importer.ts new file mode 100644 index 000000000000..8fbb6e74310f --- /dev/null +++ b/tests/e2e/tests/build/styles/sass-pkg-importer.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert'; +import { writeFile } from '../../../utils/fs'; +import { getActivePackageManager, uninstallPackage } from '../../../utils/packages'; +import { ng } from '../../../utils/process'; +import { isPrereleaseCli, updateJsonFile } from '../../../utils/project'; +import { appendFile } from 'node:fs/promises'; +import { getGlobalVariable } from '../../../utils/env'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // forcibly remove in case another test doesn't clean itself up + await uninstallPackage('@angular/material'); + + const isPrerelease = await isPrereleaseCli(); + const tag = isPrerelease ? '@next' : ''; + if (getActivePackageManager() === 'npm') { + await appendFile('.npmrc', '\nlegacy-peer-deps=true'); + } + + await ng('add', `@angular/material${tag}`, '--skip-confirmation'); + await Promise.all([ + updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + appArchitect.build.options.styles = ['src/styles.scss']; + }), + writeFile('src/styles.scss', `@use 'pkg:@angular/material' as mat;`), + ]); + + await ng('build'); +} diff --git a/tests/e2e/tests/build/styles/sass.ts b/tests/e2e/tests/build/styles/sass.ts new file mode 100644 index 000000000000..45fd969b4c7b --- /dev/null +++ b/tests/e2e/tests/build/styles/sass.ts @@ -0,0 +1,54 @@ +import { + writeMultipleFiles, + deleteFile, + expectFileToMatch, + replaceInFile, +} from '../../../utils/fs'; +import { expectToFail } from '../../../utils/utils'; +import { ng } from '../../../utils/process'; +import { updateJsonFile } from '../../../utils/project'; + +export default async function () { + await writeMultipleFiles({ + 'src/styles.sass': ` +@import './imported-styles.sass' +body + background-color: blue +`, + 'src/imported-styles.sass': ` +p + background-color: red +`, + 'src/app/app.sass': ` +.outer + .inner + background: #fff +`, + }); + + await updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + appArchitect.build.options.styles = [{ input: 'src/styles.sass' }]; + }); + + await deleteFile('src/app/app.css'); + await replaceInFile('src/app/app.ts', './app.css', './app.sass'); + + await ng('build', '--source-map', '--configuration=development'); + + await expectFileToMatch( + 'dist/test-project/browser/styles.css', + /body\s*{\s*background-color: blue;\s*}/, + ); + await expectFileToMatch( + 'dist/test-project/browser/styles.css', + /p\s*{\s*background-color: red;\s*}/, + ); + await expectToFail(() => + expectFileToMatch('dist/test-project/browser/styles.css', '"mappings":""'), + ); + await expectFileToMatch( + 'dist/test-project/browser/main.js', + /.outer.*.inner.*background:\s*#[fF]+/, + ); +} diff --git a/tests/e2e/tests/build/styles/scss-partial-resolution.ts b/tests/e2e/tests/build/styles/scss-partial-resolution.ts new file mode 100644 index 000000000000..1a555b26e23b --- /dev/null +++ b/tests/e2e/tests/build/styles/scss-partial-resolution.ts @@ -0,0 +1,35 @@ +import { installPackage } from '../../../utils/packages'; +import { writeMultipleFiles, deleteFile, replaceInFile } from '../../../utils/fs'; +import { ng } from '../../../utils/process'; +import { updateJsonFile } from '../../../utils/project'; + +export default async function () { + // Supports resolving node_modules with are pointing to partial files partial files. + // @material/button/button below points to @material/button/_button.scss + // https://unpkg.com/browse/@material/button@14.0.0/_button.scss + + await installPackage('@material/button@14.0.0'); + + await writeMultipleFiles({ + 'src/styles.scss': ` + @use '@material/button/button'; + + @include button.core-styles; + `, + 'src/app/app.scss': ` + @use '@material/button/button'; + + @include button.core-styles; + `, + }); + + await updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + appArchitect.build.options.styles = ['src/styles.scss']; + }); + + await deleteFile('src/app/app.css'); + await replaceInFile('src/app/app.ts', './app.css', './app.scss'); + + await ng('build', '--configuration=development'); +} diff --git a/tests/e2e/tests/build/styles/scss.ts b/tests/e2e/tests/build/styles/scss.ts index 9ba76e715617..69fcc1c0f060 100644 --- a/tests/e2e/tests/build/styles/scss.ts +++ b/tests/e2e/tests/build/styles/scss.ts @@ -2,39 +2,51 @@ import { writeMultipleFiles, deleteFile, expectFileToMatch, - replaceInFile + replaceInFile, } from '../../../utils/fs'; +import { expectToFail } from '../../../utils/utils'; import { ng } from '../../../utils/process'; -import { stripIndents } from 'common-tags'; import { updateJsonFile } from '../../../utils/project'; -export default function () { - return writeMultipleFiles({ - 'src/styles.scss': stripIndents` +export default async function () { + await writeMultipleFiles({ + 'src/styles.scss': ` @import './imported-styles.scss'; body { background-color: blue; } `, - 'src/imported-styles.scss': stripIndents` - p { background-color: red; } - `, - 'src/app/app.component.scss': stripIndents` + 'src/imported-styles.scss': 'p { background-color: red; }', + 'src/app/app.scss': ` .outer { .inner { background: #fff; } } - `}) - .then(() => deleteFile('src/app/app.component.css')) - .then(() => updateJsonFile('angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['styles'] = ['styles.scss']; - })) - .then(() => replaceInFile('src/app/app.component.ts', - './app.component.css', './app.component.scss')) - .then(() => ng('build')) - .then(() => expectFileToMatch('dist/styles.bundle.css', - /body\s*{\s*background-color: blue;\s*}/)) - .then(() => expectFileToMatch('dist/styles.bundle.css', - /p\s*{\s*background-color: red;\s*}/)) - .then(() => expectFileToMatch('dist/main.bundle.js', /.outer.*.inner.*background:\s*#[fF]+/)); + `, + }); + + await updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + appArchitect.build.options.styles = [{ input: 'src/styles.scss' }]; + }); + + await deleteFile('src/app/app.css'); + await replaceInFile('src/app/app.ts', './app.css', './app.scss'); + + await ng('build', '--source-map', '--configuration=development'); + + await expectFileToMatch( + 'dist/test-project/browser/styles.css', + /body\s*{\s*background-color: blue;\s*}/, + ); + await expectFileToMatch( + 'dist/test-project/browser/styles.css', + /p\s*{\s*background-color: red;\s*}/, + ); + await expectToFail(() => + expectFileToMatch('dist/test-project/browser/styles.css', '"mappings":""'), + ); + await expectFileToMatch( + 'dist/test-project/browser/main.js', + /.outer.*.inner.*background:\s*#[fF]+/, + ); } diff --git a/tests/e2e/tests/build/styles/styles-array.ts b/tests/e2e/tests/build/styles/styles-array.ts deleted file mode 100644 index ff913bd63afd..000000000000 --- a/tests/e2e/tests/build/styles/styles-array.ts +++ /dev/null @@ -1,59 +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 () { - 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 }', - 'src/common-entry-style.css': '.common-entry-style { color: red }', - 'src/common-entry-script.js': 'console.log(\'common-entry-script\');' - }) - .then(() => updateJsonFile('angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['styles'] = [ - 'string-style.css', - { input: 'input-style.css' }, - { input: 'lazy-style.css', lazy: true }, - { input: 'pre-rename-style.css', output: 'renamed-style' }, - { input: 'pre-rename-lazy-style.css', output: 'renamed-lazy-style', lazy: true }, - { input: 'common-entry-style.css', output: 'common-entry' } - ]; - app['scripts'] = [{ input: 'common-entry-script.js', output: 'common-entry' }]; - })) - .then(() => ng('build')) - // files were created successfully - .then(() => expectFileToMatch('dist/styles.bundle.css', '.string-style')) - .then(() => expectFileToMatch('dist/styles.bundle.css', '.input-style')) - .then(() => expectFileToMatch('dist/lazy-style.bundle.css', '.lazy-style')) - .then(() => expectFileToMatch('dist/renamed-style.bundle.css', '.pre-rename-style')) - .then(() => expectFileToMatch('dist/renamed-lazy-style.bundle.css', '.pre-rename-lazy-style')) - .then(() => expectFileToMatch('dist/common-entry.bundle.css', '.common-entry-style')) - .then(() => expectFileToMatch('dist/common-entry.bundle.js', 'common-entry-script')) - // there are no js entry points for css only bundles - .then(() => expectToFail(() => expectFileToExist('dist/styles.bundle.js'))) - .then(() => expectToFail(() => expectFileToExist('dist/lazy-styles.bundle.js'))) - .then(() => expectToFail(() => expectFileToExist('dist/renamed-styles.bundle.js'))) - .then(() => expectToFail(() => expectFileToExist('dist/renamed-lazy-styles.bundle.js'))) - // index.html lists the right bundles - .then(() => expectFileToMatch('dist/index.html', oneLineTrim` - - - - `)) - .then(() => expectFileToMatch('dist/index.html', oneLineTrim` - - - - - `)); -} diff --git a/tests/e2e/tests/build/styles/stylus.ts b/tests/e2e/tests/build/styles/stylus.ts deleted file mode 100644 index b318dfa73d24..000000000000 --- a/tests/e2e/tests/build/styles/stylus.ts +++ /dev/null @@ -1,40 +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'; - -export default function () { - return writeMultipleFiles({ - 'src/styles.styl': stripIndents` - @import './imported-styles.styl'; - body { background-color: blue; } - `, - 'src/imported-styles.styl': stripIndents` - p { background-color: red; } - `, - 'src/app/app.component.styl': stripIndents` - .outer { - .inner { - background: #fff; - } - } - `}) - .then(() => deleteFile('src/app/app.component.css')) - .then(() => updateJsonFile('angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['styles'] = ['styles.styl']; - })) - .then(() => replaceInFile('src/app/app.component.ts', - './app.component.css', './app.component.styl')) - .then(() => ng('build')) - .then(() => expectFileToMatch('dist/styles.bundle.css', - /body\s*{\s*background-color: #00f;\s*}/)) - .then(() => expectFileToMatch('dist/styles.bundle.css', - /p\s*{\s*background-color: #f00;\s*}/)) - .then(() => expectFileToMatch('dist/main.bundle.js', /.outer.*.inner.*background:\s*#[fF]+/)); -} diff --git a/tests/e2e/tests/build/styles/symlinked-global.ts b/tests/e2e/tests/build/styles/symlinked-global.ts new file mode 100644 index 000000000000..b38663b321e9 --- /dev/null +++ b/tests/e2e/tests/build/styles/symlinked-global.ts @@ -0,0 +1,27 @@ +import { symlinkSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { expectFileToMatch, writeMultipleFiles } from '../../../utils/fs'; +import { ng } from '../../../utils/process'; +import { updateJsonFile } from '../../../utils/project'; + +export default async function () { + await writeMultipleFiles({ + 'src/styles.scss': `p { color: red }`, + 'src/styles-for-link.scss': `p { color: blue }`, + }); + + symlinkSync(resolve('src/styles-for-link.scss'), resolve('src/styles-linked.scss')); + + await updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + appArchitect.build.options.styles = ['src/styles.scss', 'src/styles-linked.scss']; + }); + + await ng('build', '--configuration=development'); + await expectFileToMatch('dist/test-project/browser/styles.css', 'red'); + await expectFileToMatch('dist/test-project/browser/styles.css', 'blue'); + + await ng('build', '--preserve-symlinks', '--configuration=development'); + await expectFileToMatch('dist/test-project/browser/styles.css', 'red'); + await expectFileToMatch('dist/test-project/browser/styles.css', 'blue'); +} diff --git a/tests/e2e/tests/build/styles/tailwind-v2.ts b/tests/e2e/tests/build/styles/tailwind-v2.ts new file mode 100644 index 000000000000..f081e040dcb5 --- /dev/null +++ b/tests/e2e/tests/build/styles/tailwind-v2.ts @@ -0,0 +1,74 @@ +import { deleteFile, expectFileToMatch, writeFile } from '../../../utils/fs'; +import { installPackage, uninstallPackage } from '../../../utils/packages'; +import { ng, silentExec } from '../../../utils/process'; +import { expectToFail } from '../../../utils/utils'; + +export default async function () { + // Temporarily turn off caching until the build cache accounts for the presence of tailwind + // and its configuration file. Otherwise cached builds without tailwind will cause test failures. + await ng('cache', 'off'); + + // Create configuration file + await silentExec('npx', 'tailwindcss@2', 'init'); + + // Add Tailwind directives to a component style + await writeFile('src/app/app.css', '@tailwind base; @tailwind components;'); + + // Add Tailwind directives to a global style + await writeFile('src/styles.css', '@tailwind base; @tailwind components;'); + + // Ensure installation warning is present + const { stderr } = await ng('build', '--configuration=development'); + if (!stderr.includes("To enable Tailwind CSS, please install the 'tailwindcss' package.")) { + throw new Error(`Expected tailwind installation warning. STDERR:\n${stderr}`); + } + + // Tailwind directives should be unprocessed with missing package + await expectFileToMatch( + 'dist/test-project/browser/styles.css', + /@tailwind base;\s+@tailwind components;/, + ); + await expectFileToMatch( + 'dist/test-project/browser/main.js', + /@tailwind base;(?:\\n|\s*)@tailwind components;/, + ); + + // Install Tailwind + await installPackage('tailwindcss@2'); + + // Build should succeed and process Tailwind directives + await ng('build', '--configuration=development'); + + // Check for Tailwind output + await expectFileToMatch('dist/test-project/browser/styles.css', /::placeholder/); + await expectFileToMatch('dist/test-project/browser/main.js', /::placeholder/); + await expectToFail(() => + expectFileToMatch( + 'dist/test-project/browser/styles.css', + /@tailwind base;\s+@tailwind components;/, + ), + ); + await expectToFail(() => + expectFileToMatch( + 'dist/test-project/browser/main.js', + /@tailwind base;(?:\\n|\s*)@tailwind components;/, + ), + ); + + // Remove configuration file + await deleteFile('tailwind.config.js'); + + // Ensure Tailwind is disabled when no configuration file is present + await ng('build', '--configuration=development'); + await expectFileToMatch( + 'dist/test-project/browser/styles.css', + /@tailwind base;\s+@tailwind components;/, + ); + await expectFileToMatch( + 'dist/test-project/browser/main.js', + /@tailwind base;(?:\\n|\s*)@tailwind components;/, + ); + + // Uninstall Tailwind + await uninstallPackage('tailwindcss'); +} diff --git a/tests/e2e/tests/build/styles/tailwind-v3-cjs.ts b/tests/e2e/tests/build/styles/tailwind-v3-cjs.ts new file mode 100644 index 000000000000..c69f56aa7af4 --- /dev/null +++ b/tests/e2e/tests/build/styles/tailwind-v3-cjs.ts @@ -0,0 +1,40 @@ +import { expectFileToMatch, writeFile } from '../../../utils/fs'; +import { installPackage, uninstallPackage } from '../../../utils/packages'; +import { ng, silentExec } from '../../../utils/process'; +import { updateJsonFile } from '../../../utils/project'; +import { expectToFail } from '../../../utils/utils'; + +export default async function () { + // Temporarily turn off caching until the build cache accounts for the presence of tailwind + // and its configuration file. Otherwise cached builds without tailwind will cause test failures. + await ng('cache', 'off'); + + // Add type module in package.json. + await updateJsonFile('package.json', (json) => { + json['type'] = 'module'; + }); + + // Install Tailwind + await installPackage('tailwindcss@3'); + + // Create configuration file + await silentExec('npx', 'tailwindcss', 'init'); + + // Add Tailwind directives to a global style + await writeFile('src/styles.css', '@tailwind base; @tailwind components;'); + + // Build should succeed and process Tailwind directives + await ng('build', '--configuration=development'); + + // Check for Tailwind output + await expectFileToMatch('dist/test-project/browser/styles.css', /::placeholder/); + await expectToFail(() => + expectFileToMatch( + 'dist/test-project/browser/styles.css', + /@tailwind base;\s+@tailwind components;/, + ), + ); + + // Uninstall Tailwind + await uninstallPackage('tailwindcss'); +} diff --git a/tests/e2e/tests/build/styles/tailwind-v3.ts b/tests/e2e/tests/build/styles/tailwind-v3.ts new file mode 100644 index 000000000000..97700a4c4b3e --- /dev/null +++ b/tests/e2e/tests/build/styles/tailwind-v3.ts @@ -0,0 +1,108 @@ +import { deleteFile, expectFileToMatch, rimraf, writeFile } from '../../../utils/fs'; +import { installPackage, uninstallPackage } from '../../../utils/packages'; +import { ng, silentExec } from '../../../utils/process'; +import { expectToFail } from '../../../utils/utils'; + +export default async function () { + // Temporarily turn off caching until the build cache accounts for the presence of tailwind + // and its configuration file. Otherwise cached builds without tailwind will cause test failures. + await ng('cache', 'off'); + + // In case a previous test installed tailwindcss, clear it. + // (we don't clear node module directories between tests) + await rimraf('node_modules/tailwindcss'); + + // Create configuration file + await silentExec('npx', 'tailwindcss@3', 'init'); + + // Add Tailwind directives to a component style + await writeFile('src/app/app.css', '@tailwind base; @tailwind components;'); + + // Add Tailwind directives to a global style + await writeFile( + 'src/styles.css', + ` + @import url(https://fonts.googleapis.com/css?family=Roboto:400); + @tailwind base; + @tailwind components; + `, + ); + + // Ensure installation warning is present + const { stderr } = await ng('build', '--configuration=development'); + if (!stderr.includes("To enable Tailwind CSS, please install the 'tailwindcss' package.")) { + throw new Error('Expected tailwind installation warning'); + } + + // Tailwind directives should be unprocessed with missing package + await expectFileToMatch( + 'dist/test-project/browser/styles.css', + /@tailwind base;\s+@tailwind components;/, + ); + await expectFileToMatch( + 'dist/test-project/browser/main.js', + /@tailwind base;(?:\\n|\s*)@tailwind components;/, + ); + + // Install Tailwind + await installPackage('tailwindcss@3'); + + // Build should succeed and process Tailwind directives + await ng('build', '--configuration=development'); + + // Check for Tailwind output + await expectFileToMatch('dist/test-project/browser/styles.css', /::placeholder/); + await expectFileToMatch('dist/test-project/browser/main.js', /::placeholder/); + await expectToFail(() => + expectFileToMatch( + 'dist/test-project/browser/styles.css', + /@tailwind base;\s+@tailwind components;/, + ), + ); + await expectToFail(() => + expectFileToMatch( + 'dist/test-project/browser/main.js', + /@tailwind base;(?:\\n|\s*)@tailwind components;/, + ), + ); + + // Add Tailwind directives to an imported global style + await writeFile('src/tailwind.scss', '@tailwind base; @tailwind components;'); + await writeFile('src/styles.css', '@import "./tailwind.scss";'); + + // Build should succeed and process Tailwind directives + await ng('build', '--configuration=development'); + + // Check for Tailwind output + await expectFileToMatch('dist/test-project/browser/styles.css', /::placeholder/); + await expectFileToMatch('dist/test-project/browser/main.js', /::placeholder/); + await expectToFail(() => + expectFileToMatch( + 'dist/test-project/browser/styles.css', + /@tailwind base;\s+@tailwind components;/, + ), + ); + await expectToFail(() => + expectFileToMatch( + 'dist/test-project/browser/main.js', + /@tailwind base;(?:\\n|\s*)@tailwind components;/, + ), + ); + + // Remove configuration file + await deleteFile('tailwind.config.js'); + + // Ensure Tailwind is disabled when no configuration file is present + await ng('build', '--configuration=development'); + await expectFileToMatch( + 'dist/test-project/browser/styles.css', + /@tailwind base;\s+@tailwind components;/, + ); + await expectFileToMatch( + 'dist/test-project/browser/main.js', + /@tailwind base;(?:\\n|\s*)@tailwind components;/, + ); + + // Uninstall Tailwind + await uninstallPackage('tailwindcss'); +} diff --git a/tests/e2e/tests/build/ts-paths.ts b/tests/e2e/tests/build/ts-paths.ts index 8a39a803a1e8..1461aaef2636 100644 --- a/tests/e2e/tests/build/ts-paths.ts +++ b/tests/e2e/tests/build/ts-paths.ts @@ -1,36 +1,53 @@ -import {updateTsConfig} from '../../utils/project'; -import {writeMultipleFiles, appendToFile, createDir} from '../../utils/fs'; -import {ng} from '../../utils/process'; -import {stripIndents} from 'common-tags'; +import { appendToFile, createDir, replaceInFile, rimraf, writeMultipleFiles } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { updateTsConfig } from '../../utils/project'; - -export default function() { - return updateTsConfig(json => { - json['compilerOptions']['baseUrl'] = '.'; +export default async function () { + await updateTsConfig((json) => { json['compilerOptions']['paths'] = { - '@shared': [ - 'app/shared' - ], - '@shared/*': [ - 'app/shared/*' - ] + '@shared': ['./src/app/shared'], + '@shared/*': ['./src/app/shared/*'], + '@root/*': ['./src/*'], + 'src/*': ['./src/*'], }; - }) - .then(() => createDir('src/app/shared')) - .then(() => writeMultipleFiles({ + }); + + await createDir('src/app/shared'); + await writeMultipleFiles({ + 'src/meaning-too.ts': 'export var meaning = 42;', 'src/app/shared/meaning.ts': 'export var meaning = 42;', - 'src/app/shared/index.ts': `export * from './meaning'` - })) - .then(() => appendToFile('src/app/app.component.ts', stripIndents` - import { meaning } from 'app/shared/meaning'; + '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) - `)) - .then(() => ng('build')); + 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/vendor-chunk.ts b/tests/e2e/tests/build/vendor-chunk.ts deleted file mode 100644 index cafc19e3371b..000000000000 --- a/tests/e2e/tests/build/vendor-chunk.ts +++ /dev/null @@ -1,11 +0,0 @@ -import {ng} from '../../utils/process'; -import {expectFileToExist} from '../../utils/fs'; -import {expectToFail} from '../../utils/utils'; - - -export default function() { - return ng('build') - .then(() => expectFileToExist('dist/vendor.bundle.js')) - .then(() => ng('build', '--no-vendor-chunk')) - .then(() => expectToFail(() => expectFileToExist('dist/vendor.bundle.js'))); -} diff --git a/tests/e2e/tests/build/wasm-esm.ts b/tests/e2e/tests/build/wasm-esm.ts new file mode 100644 index 000000000000..70633a1021c1 --- /dev/null +++ b/tests/e2e/tests/build/wasm-esm.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ +import { readFile, writeFile } from 'node:fs/promises'; +import assert from 'node:assert/strict'; +import { ng } from '../../utils/process'; +import { prependToFile, replaceInFile } from '../../utils/fs'; +import { updateJsonFile, useSha } from '../../utils/project'; +import { installWorkspacePackages } from '../../utils/packages'; +import { executeBrowserTest } from '../../utils/puppeteer'; + +/** + * Compiled and base64 encoded WASM file for the following WAT: + * ``` + * (module + * (import "./values" "getValue" (func $getvalue (result i32))) + * (export "multiply" (func $multiply)) + * (export "subtract1" (func $subtract)) + * (func $multiply (param i32 i32) (result i32) + * local.get 0 + * local.get 1 + * i32.mul + * ) + * (func $subtract (param i32) (result i32) + * call $getvalue + * local.get 0 + * i32.sub + * ) + * ) + * ``` + */ +const importWasmBase64 = + 'AGFzbQEAAAABEANgAAF/YAJ/fwF/YAF/AX8CFQEILi92YWx1ZXMIZ2V0VmFsdWUAAAMDAgECBxgCCG11bHRpcGx5AAEJc3VidHJhY3QxAAIKEQIHACAAIAFsCwcAEAAgAGsLAC8EbmFtZQEfAwAIZ2V0dmFsdWUBCG11bHRpcGx5AghzdWJ0cmFjdAIHAwAAAQACAA=='; +const importWasmBytes = Buffer.from(importWasmBase64, 'base64'); + +export default async function () { + // Add WASM file to project + await writeFile('src/app/multiply.wasm', importWasmBytes); + await writeFile( + 'src/app/multiply.wasm.d.ts', + 'export declare function multiply(a: number, b: number): number; export declare function subtract1(a: number): number;', + ); + + // Add requested WASM import file + await writeFile('src/app/values.js', 'export function getValue() { return 100; }'); + + // Use WASM file in project + await prependToFile( + 'src/app/app.ts', + ` + import { multiply, subtract1 } from './multiply.wasm'; + `, + ); + await replaceInFile('src/app/app.ts', "'test-project'", 'multiply(4, 5) + subtract1(88)'); + + // Remove Zone.js from polyfills and make zoneless + await updateJsonFile('angular.json', (json) => { + // Remove bundle budgets to avoid a build error due to the expected increased output size + // of a JIT production build. + json.projects['test-project'].architect.build.options.polyfills = []; + }); + + await ng('build'); + + // Update E2E test to check for WASM execution + await executeBrowserTest({ expectedTitleText: 'Hello, 32' }); + + // Setup prerendering and build to test Node.js functionality + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + await ng('build', '--configuration', 'development', '--prerender'); + const content = await readFile('dist/test-project/browser/index.html', 'utf-8'); + assert.match(content, /Hello, 32/); +} diff --git a/tests/e2e/tests/build/worker.ts b/tests/e2e/tests/build/worker.ts new file mode 100644 index 000000000000..be81aae7dfd4 --- /dev/null +++ b/tests/e2e/tests/build/worker.ts @@ -0,0 +1,100 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import assert from 'node:assert/strict'; +import { readdir } from 'node:fs/promises'; +import { setTimeout } from 'node:timers/promises'; +import { getGlobalVariable } from '../../utils/env'; +import { expectFileToExist, expectFileToMatch, replaceInFile, writeFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { expectToFail } from '../../utils/utils'; +import { executeBrowserTest } from '../../utils/puppeteer'; + +export default async function () { + const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; + + const workerPath = 'src/app/app.worker.ts'; + const snippetPath = 'src/app/app.ts'; + const projectTsConfig = 'tsconfig.json'; + const workerTsConfig = 'tsconfig.worker.json'; + + await ng('generate', 'web-worker', 'app'); + await expectFileToExist(workerPath); + await expectFileToExist(projectTsConfig); + await expectFileToExist(workerTsConfig); + await expectFileToMatch(snippetPath, `new Worker(new URL('./app.worker', import.meta.url)`); + + await ng('build', '--configuration=development'); + if (useWebpackBuilder) { + await expectFileToExist('dist/test-project/browser/src_app_app_worker_ts.js'); + await expectFileToMatch('dist/test-project/browser/main.js', 'src_app_app_worker_ts'); + } else { + const workerOutputFile = await getWorkerOutputFile(false); + await expectFileToExist(`dist/test-project/browser/${workerOutputFile}`); + await expectFileToMatch('dist/test-project/browser/main.js', workerOutputFile); + await expectToFail(() => + expectFileToMatch('dist/test-project/browser/main.js', workerOutputFile + '.map'), + ); + } + + await ng('build', '--output-hashing=none'); + + const workerOutputFile = await getWorkerOutputFile(useWebpackBuilder); + await expectFileToExist(`dist/test-project/browser/${workerOutputFile}`); + if (useWebpackBuilder) { + // Check Webpack builds for the numeric chunk identifier + await expectFileToMatch('dist/test-project/browser/main.js', workerOutputFile.substring(0, 3)); + } else { + await expectFileToMatch('dist/test-project/browser/main.js', workerOutputFile); + } + + // console.warn has to be used because chrome only captures warnings and errors by default + // https://github.com/angular/protractor/issues/2207 + await replaceInFile('src/app/app.ts', 'console.log', 'console.warn'); + + await executeBrowserTest({ + checkFn: async (page) => { + const messages: string[] = []; + page.on('console', (msg) => { + messages.push(msg.text()); + }); + + // Reload to ensure we capture messages from the start if needed, + // although executeBrowserTest already navigated. + await page.reload(); + + // Wait for the worker message + let retries = 50; + while ( + !messages.some((m) => m.includes('page got message: worker response to hello')) && + retries > 0 + ) { + await setTimeout(100); + retries--; + } + + if (!messages.some((m) => m.includes('page got message: worker response to hello'))) { + assert.fail(`Expected worker message not found in console. Got:\n${messages.join('\n')}`); + } + }, + }); +} + +async function getWorkerOutputFile(useWebpackBuilder: boolean): Promise { + const files = await readdir('dist/test-project/browser'); + let fileName; + if (useWebpackBuilder) { + fileName = files.find((f) => /^\d{3}\.js$/.test(f)); + } else { + fileName = files.find((f) => /worker-[\dA-Z]{8}\.js/.test(f)); + } + + assert(fileName, 'Cannot determine worker output file name.'); + + return fileName; +} diff --git a/tests/e2e/tests/commands/add/add-material.ts b/tests/e2e/tests/commands/add/add-material.ts new file mode 100644 index 000000000000..a7453f035a6f --- /dev/null +++ b/tests/e2e/tests/commands/add/add-material.ts @@ -0,0 +1,46 @@ +import { assertIsError } from '../../../utils/utils'; +import { readFile, rimraf } from '../../../utils/fs'; +import { getActivePackageManager, uninstallPackage } from '../../../utils/packages'; +import { ng } from '../../../utils/process'; +import { isPrereleaseCli } from '../../../utils/project'; +import { appendFile } from 'node:fs/promises'; +import assert from 'node:assert'; + +export default async function () { + // forcibly remove in case another test doesn't clean itself up + await rimraf('node_modules/@angular/material'); + + const isPrerelease = await isPrereleaseCli(); + const tag = isPrerelease ? '@next' : ''; + if (getActivePackageManager() === 'npm') { + await appendFile('.npmrc', '\nlegacy-peer-deps=true'); + } + + try { + await ng('add', `@angular/material${tag}`, '--unknown', '--skip-confirmation'); + } catch (error) { + assertIsError(error); + if (!(error as Error).message.includes(`Unknown option: '--unknown'`)) { + throw error; + } + } + + await ng( + 'add', + `@angular/material${tag}`, + '--theme', + 'azure-blue', + '--verbose', + '--skip-confirmation', + ); + + const { dependencies } = JSON.parse(await readFile('package.json')); + assert.ok( + dependencies['@angular/material'], + '`@angular/material` was not found added to dependencies', + ); + + // Clean up existing cdk package + // Not doing so can cause adding material to fail if an incompatible cdk is present + await uninstallPackage('@angular/cdk'); +} diff --git a/tests/e2e/tests/commands/add/add-pwa.ts b/tests/e2e/tests/commands/add/add-pwa.ts new file mode 100644 index 000000000000..c01cc185c84b --- /dev/null +++ b/tests/e2e/tests/commands/add/add-pwa.ts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import { getGlobalVariable } from '../../../utils/env'; +import { expectFileToExist, readFile, rimraf } from '../../../utils/fs'; +import { installWorkspacePackages } from '../../../utils/packages'; +import { ng } from '../../../utils/process'; +import { updateJsonFile } from '../../../utils/project'; + +const snapshots = require('../../../ng-snapshot/package.json'); + +export default async function () { + // forcibly remove in case another test doesn't clean itself up + await rimraf('node_modules/@angular/pwa'); + await ng('add', '@angular/pwa', '--skip-confirmation'); + await expectFileToExist('public/manifest.webmanifest'); + + // Angular PWA doesn't install as a dependency + const { dependencies, devDependencies } = JSON.parse(await readFile('package.json')); + const hasPWADep = Object.keys({ ...dependencies, ...devDependencies }).some( + (d) => d === '@angular/pwa', + ); + assert.ok(!hasPWADep, `Expected 'package.json' not to contain a dependency on '@angular/pwa'.`); + + const isSnapshotBuild = getGlobalVariable('argv')['ng-snapshots']; + if (isSnapshotBuild) { + let needInstall = false; + await updateJsonFile('package.json', (packageJson) => { + const dependencies = packageJson['dependencies']; + // Iterate over all of the packages to update them to the snapshot version. + for (const [name, version] of Object.entries(snapshots.dependencies)) { + if (name in dependencies && dependencies[name] !== version) { + dependencies[name] = version; + needInstall = true; + } + } + }); + + if (needInstall) { + await installWorkspacePackages(); + } + } + + // It should generate a SW configuration file (`ngsw.json`). + const ngswPath = 'dist/test-project/browser/ngsw.json'; + + await ng('build'); + await expectFileToExist(ngswPath); + + // It should correctly generate assetGroups and include at least one URL in each group. + const ngswJson = JSON.parse(await readFile(ngswPath)); + // @ts-ignore + const assetGroups: any[] = ngswJson.assetGroups.map(({ name, urls }) => ({ + name, + urlCount: urls.length, + })); + const emptyAssetGroups = assetGroups.filter(({ urlCount }) => urlCount === 0); + + assert.ok(assetGroups.length > 0, "Expected 'ngsw.json' to contain at least one asset-group."); + assert.ok( + emptyAssetGroups.length === 0, + 'Expected all asset-groups to contain at least one URL, but the following groups are empty: ' + + emptyAssetGroups.map(({ name }) => name).join(', '), + ); +} diff --git a/tests/e2e/tests/commands/add/add-registry-stripped-schematics.ts b/tests/e2e/tests/commands/add/add-registry-stripped-schematics.ts new file mode 100644 index 000000000000..c6356dc8d92c --- /dev/null +++ b/tests/e2e/tests/commands/add/add-registry-stripped-schematics.ts @@ -0,0 +1,121 @@ +import { join } from 'node:path'; +import { promises as fs } from 'node:fs'; +import { getGlobalVariable } from '../../../utils/env'; +import { expectFileToExist, expectFileNotToExist, rimraf } from '../../../utils/fs'; +import { getActivePackageManager } from '../../../utils/packages'; +import { ng, silentNpm } from '../../../utils/process'; +import { mktempd } from '../../../utils/utils'; + +export default async function () { + const testRegistry = getGlobalVariable('package-registry'); + const tmpRoot = getGlobalVariable('tmp-root'); + + // 1. Create a temp directory for the custom package + const pkgDir = await mktempd('registry-stripped-pkg-', tmpRoot); + + try { + // 2. Write the package files + const packageJson = { + name: '@angular-devkit/ng-add-registry-stripped', + version: '1.0.0', + schematics: './collection.json', + 'ng-add': { + save: false, + }, + }; + + const collectionJson = { + schematics: { + 'ng-add': { + factory: './index.js', + description: 'Add test empty file to your application.', + }, + }, + }; + + const indexJs = ` + exports.default = function() { + return function(tree) { + tree.create('schematic-executed-successfully.txt', 'Registry Stripped schematic works!'); + return tree; + }; + }; + `; + + await fs.writeFile(join(pkgDir, 'package.json'), JSON.stringify(packageJson, null, 2)); + await fs.writeFile(join(pkgDir, 'collection.json'), JSON.stringify(collectionJson, null, 2)); + await fs.writeFile(join(pkgDir, 'index.js'), indexJs); + + // Write a temporary .npmrc with a fake authentication token so that npm publish succeeds + // without needing real credentials or throwing ENEEDAUTH. + const npmrcContent = ` +${testRegistry.replace(/^https?:/, '')}/:_authToken=fake-secret +registry=${testRegistry} +`; + await fs.writeFile(join(pkgDir, '.npmrc'), npmrcContent); + + // 3. Pack the package + const packResult = await silentNpm(['pack'], { cwd: pkgDir }); + const tarballName = packResult.stdout.trim().split('\n').pop() || ''; + + // 4. Publish the package to the local verdaccio registry + // Verdaccio has publish: $all for @angular-devkit/* so this will succeed + await silentNpm(['publish'], { cwd: pkgDir }); + + // 5. Strip "schematics" and "ng-add" from Verdaccio's metadata on disk + const verdaccioDbPath = join( + tmpRoot, + 'registry', + 'storage', + '@angular-devkit', + 'ng-add-registry-stripped', + 'package.json', + ); + + const verdaccioDb = JSON.parse(await fs.readFile(verdaccioDbPath, 'utf-8')); + + // Strip from the top-level versions list + if (verdaccioDb.versions) { + for (const versionKey of Object.keys(verdaccioDb.versions)) { + delete verdaccioDb.versions[versionKey].schematics; + delete verdaccioDb.versions[versionKey]['ng-add']; + } + } + + // Write back the modified metadata + await fs.writeFile(verdaccioDbPath, JSON.stringify(verdaccioDb, null, 2), 'utf-8'); + + // 6. Execute `ng add` on the registry-stripped package + // Ensure file doesn't already exist + await expectFileNotToExist('schematic-executed-successfully.txt'); + + await ng('add', '@angular-devkit/ng-add-registry-stripped', '--skip-confirmation'); + + // 7. Assertions + // A. The schematic executed successfully + await expectFileToExist('schematic-executed-successfully.txt'); + + // B. The dependency was pruned from package.json since save: false + const rootPackageJson = JSON.parse(await fs.readFile('package.json', 'utf-8')); + const hasDep = + (rootPackageJson.dependencies && + rootPackageJson.dependencies['@angular-devkit/ng-add-registry-stripped']) || + (rootPackageJson.devDependencies && + rootPackageJson.devDependencies['@angular-devkit/ng-add-registry-stripped']); + + if (hasDep) { + throw new Error( + 'Package @angular-devkit/ng-add-registry-stripped was not cleaned up from package.json dependencies!', + ); + } + + // C. The dependency was pruned from node_modules physical folder + // Bun intentionally does not prune unreferenced packages from node_modules automatically. + if (getActivePackageManager() !== 'bun') { + await expectFileNotToExist('node_modules/@angular-devkit/ng-add-registry-stripped'); + } + } finally { + // Cleanup temp package source folder + await rimraf(pkgDir); + } +} diff --git a/tests/e2e/tests/commands/add/add-tailwindcss.ts b/tests/e2e/tests/commands/add/add-tailwindcss.ts new file mode 100644 index 000000000000..fa42815d2fdf --- /dev/null +++ b/tests/e2e/tests/commands/add/add-tailwindcss.ts @@ -0,0 +1,39 @@ +import assert from 'node:assert'; +import { expectFileToExist, expectFileToMatch, readFile, rimraf } from '../../../utils/fs'; +import { getActivePackageManager, uninstallPackage } from '../../../utils/packages'; +import { ng } from '../../../utils/process'; + +export default async function () { + // In case a previous test installed tailwindcss, clear it. + // (we don't clear node module directories between tests) + // npm does not appear to fully uninstall sometimes + if (getActivePackageManager() === 'npm') { + await rimraf('node_modules/tailwindcss'); + } + + try { + await ng('add', 'tailwindcss', '--skip-confirmation'); + await expectFileToExist('.postcssrc.json'); + await expectFileToMatch('src/styles.css', /@import 'tailwindcss';/); + + const { dependencies, devDependencies } = JSON.parse(await readFile('package.json')); + assert.strictEqual( + dependencies?.tailwindcss, + undefined, + 'tailwindcss should not be added to dependencies.', + ); + assert.ok(devDependencies?.tailwindcss, 'tailwindcss should be added to devDependencies.'); + assert.ok( + devDependencies?.['@tailwindcss/postcss'], + '@tailwindcss/postcss should be added to devDependencies.', + ); + assert.ok(devDependencies?.postcss, 'postcss should be added to devDependencies.'); + + // Ensure the project builds + await ng('build', '--configuration=development'); + } finally { + await uninstallPackage('tailwindcss'); + await uninstallPackage('@tailwindcss/postcss'); + await uninstallPackage('postcss'); + } +} diff --git a/tests/e2e/tests/commands/add/add-version.ts b/tests/e2e/tests/commands/add/add-version.ts new file mode 100644 index 000000000000..02d63eb66e0b --- /dev/null +++ b/tests/e2e/tests/commands/add/add-version.ts @@ -0,0 +1,9 @@ +import { expectFileToExist, expectFileToMatch, rimraf } from '../../../utils/fs'; +import { ng } from '../../../utils/process'; + +export default async function () { + await ng('add', '@angular-devkit-tests/ng-add-simple@^1.0.0', '--skip-confirmation'); + await expectFileToMatch('package.json', /\/ng-add-simple.*\^1\.0\.0/); + await expectFileToExist('ng-add-test'); + await rimraf('node_modules/@angular-devkit-tests/ng-add-simple'); +} diff --git a/tests/e2e/tests/commands/add/add-vitest-browser.ts b/tests/e2e/tests/commands/add/add-vitest-browser.ts new file mode 100644 index 000000000000..7a2b780c0d8f --- /dev/null +++ b/tests/e2e/tests/commands/add/add-vitest-browser.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert'; +import { expectFileToMatch, readFile } from '../../../utils/fs'; +import { uninstallPackage } from '../../../utils/packages'; +import { ng } from '../../../utils/process'; +import { applyVitestBuilder } from '../../../utils/vitest'; + +export default async function () { + await applyVitestBuilder(); + + try { + await ng('add', '@vitest/browser-playwright', '--skip-confirmation'); + + const { dependencies, devDependencies } = JSON.parse(await readFile('package.json')); + assert.strictEqual( + dependencies?.['@vitest/browser-playwright'], + undefined, + '@vitest/browser-playwright should not be added to dependencies.', + ); + assert.ok( + devDependencies?.['@vitest/browser-playwright'], + '@vitest/browser-playwright should be added to devDependencies.', + ); + assert.ok(devDependencies?.playwright, 'playwright should be added to devDependencies.'); + + await expectFileToMatch('tsconfig.spec.json', /"vitest\/globals"/); + await expectFileToMatch('tsconfig.spec.json', /"@vitest\/browser-playwright"/); + } finally { + await uninstallPackage('@vitest/browser-playwright'); + await uninstallPackage('playwright'); + } +} diff --git a/tests/e2e/tests/commands/add/add.ts b/tests/e2e/tests/commands/add/add.ts new file mode 100644 index 000000000000..6d9827013dc4 --- /dev/null +++ b/tests/e2e/tests/commands/add/add.ts @@ -0,0 +1,9 @@ +import { expectFileToExist, expectFileToMatch, rimraf } from '../../../utils/fs'; +import { ng } from '../../../utils/process'; + +export default async function () { + await ng('add', '@angular-devkit-tests/ng-add-simple', '--skip-confirmation'); + await expectFileToMatch('package.json', /@angular-devkit-tests\/ng-add-simple/); + await expectFileToExist('ng-add-test'); + await rimraf('node_modules/@angular-devkit-tests/ng-add-simple'); +} diff --git a/tests/e2e/tests/commands/add/base.ts b/tests/e2e/tests/commands/add/base.ts new file mode 100644 index 000000000000..d31210c6c242 --- /dev/null +++ b/tests/e2e/tests/commands/add/base.ts @@ -0,0 +1,19 @@ +import { assetDir } from '../../../utils/assets'; +import { deleteFile, expectFileToExist, symlinkFile } from '../../../utils/fs'; +import { ng } from '../../../utils/process'; +import { expectToFail } from '../../../utils/utils'; + +export default async function () { + await symlinkFile(assetDir('add-collection-dir'), `./node_modules/add-collection`, 'dir'); + + await ng('add', 'add-collection'); + await expectFileToExist('empty-file'); + + await ng('add', 'add-collection', '--name=blah'); + await expectFileToExist('blah'); + + await expectToFail(() => ng('add', 'add-collection')); // File already exists. + + // Cleanup the package + await deleteFile('node_modules/add-collection'); +} diff --git a/tests/e2e/tests/commands/add/dir.ts b/tests/e2e/tests/commands/add/dir.ts new file mode 100644 index 000000000000..7cb00704cc8e --- /dev/null +++ b/tests/e2e/tests/commands/add/dir.ts @@ -0,0 +1,19 @@ +import { cp } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { assetDir } from '../../../utils/assets'; +import { expectFileToExist } from '../../../utils/fs'; +import { ng } from '../../../utils/process'; + +export default async function () { + const collectionName = 'add-collection-dir'; + const dirCollectionPath = resolve(collectionName); + + // Copy locally as bun doesn't install the dependency correctly if it has symlinks. + await cp(assetDir(collectionName), dirCollectionPath, { + recursive: true, + dereference: true, + }); + + await ng('add', dirCollectionPath, '--name=blah', '--skip-confirmation'); + await expectFileToExist('blah'); +} diff --git a/tests/e2e/tests/commands/add/file.ts b/tests/e2e/tests/commands/add/file.ts new file mode 100644 index 000000000000..5b22b4211ceb --- /dev/null +++ b/tests/e2e/tests/commands/add/file.ts @@ -0,0 +1,13 @@ +import { copyFile } from 'node:fs/promises'; +import { assetDir } from '../../../utils/assets'; +import { expectFileToExist } from '../../../utils/fs'; +import { ng } from '../../../utils/process'; + +export default async function () { + // Avoids ERR_PNPM_ENAMETOOLONG errors. + const tarball = './add-collection.tgz'; + await copyFile(assetDir(tarball), tarball); + + await ng('add', tarball, '--name=blah', '--skip-confirmation'); + await expectFileToExist('blah'); +} diff --git a/tests/e2e/tests/commands/add/npm-config.ts b/tests/e2e/tests/commands/add/npm-config.ts new file mode 100644 index 000000000000..6f7599e8c00c --- /dev/null +++ b/tests/e2e/tests/commands/add/npm-config.ts @@ -0,0 +1,8 @@ +import { writeFile } from '../../../utils/fs'; +import { ng } from '../../../utils/process'; + +export default async function () { + // Works with before option + await writeFile('.npmrc', `before=${new Date().toISOString()}`); + await ng('add', '@angular/pwa', '--skip-confirmation'); +} diff --git a/tests/e2e/tests/commands/add/peer.ts b/tests/e2e/tests/commands/add/peer.ts new file mode 100644 index 000000000000..143542e4533c --- /dev/null +++ b/tests/e2e/tests/commands/add/peer.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { resolve } from 'node:path'; +import { cp } from 'node:fs/promises'; +import { assetDir } from '../../../utils/assets'; +import { ng } from '../../../utils/process'; + +export default async function (): Promise { + const warning = /Adding the package may not succeed/; + + const stdout1 = await runNgAdd('add-collection-peer-bad'); + assert.match( + stdout1, + warning, + `Peer warning should be shown for add-collection-peer-bad but was not.`, + ); + + const stdout2 = await runNgAdd('add-collection-dir'); + assert.doesNotMatch( + stdout2, + warning, + `Peer warning should NOT be shown for add-collection-dir but was.`, + ); + + const stdout3 = await runNgAdd('add-collection-peer-good'); + assert.doesNotMatch( + stdout3, + warning, + `Peer warning should NOT be shown for add-collection-peer-good but was.`, + ); +} + +async function runNgAdd(collectionName: string): Promise { + const collectionPath = resolve(collectionName); + + // Copy locally as bun doesn't install the dependency correctly if it has symlinks. + await cp(assetDir(collectionName), collectionPath, { + recursive: true, + dereference: true, + }); + + const { stdout } = await ng('add', collectionPath, '--skip-confirmation'); + + return stdout; +} diff --git a/tests/e2e/tests/commands/add/registry-option.ts b/tests/e2e/tests/commands/add/registry-option.ts new file mode 100644 index 000000000000..1343cebf4dd1 --- /dev/null +++ b/tests/e2e/tests/commands/add/registry-option.ts @@ -0,0 +1,42 @@ +import { getGlobalVariable } from '../../../utils/env'; +import { expectFileToExist } from '../../../utils/fs'; +import { execAndCaptureError, ng } from '../../../utils/process'; +import { expectToFail } from '../../../utils/utils'; + +export default async function () { + const testRegistry = getGlobalVariable('package-registry'); + + // Validate that a non-URL registry string fails with a validation error + const error = await execAndCaptureError('ng', [ + 'add', + '--registry=not-a-valid-url', + '@angular/pwa', + '--skip-confirmation', + ]); + if (!error.message.includes('Option --registry must be a valid URL.')) { + throw new Error( + `Expected registry validation error, but got different error: ${error.message}`, + ); + } + + // Validate that an empty registry string fails with a validation error + const errorEmpty = await execAndCaptureError('ng', [ + 'add', + '--registry=', + '@angular/pwa', + '--skip-confirmation', + ]); + if (!errorEmpty.message.includes('Option --registry must be a valid URL.')) { + throw new Error( + `Expected registry validation error for empty string, but got: ${errorEmpty.message}`, + ); + } + + // Set an invalid registry + process.env['NPM_CONFIG_REGISTRY'] = 'http://127.0.0.1:9999'; + + await expectToFail(() => ng('add', '@angular/pwa', '--skip-confirmation')); + + await ng('add', `--registry=${testRegistry}`, '@angular/pwa', '--skip-confirmation'); + await expectFileToExist('public/manifest.webmanifest'); +} diff --git a/tests/e2e/tests/commands/add/secure-registry.ts b/tests/e2e/tests/commands/add/secure-registry.ts new file mode 100644 index 000000000000..e585444b02e4 --- /dev/null +++ b/tests/e2e/tests/commands/add/secure-registry.ts @@ -0,0 +1,30 @@ +import { expectFileNotToExist, expectFileToExist } from '../../../utils/fs'; +import { installWorkspacePackages } from '../../../utils/packages'; +import { git, ng } from '../../../utils/process'; +import { createNpmConfigForAuthentication } from '../../../utils/registry'; +import { expectToFail } from '../../../utils/utils'; + +export default async function () { + const originalNpmConfigRegistry = process.env['NPM_CONFIG_REGISTRY']; + try { + // The environment variable has priority over the .npmrc + delete process.env['NPM_CONFIG_REGISTRY']; + const command = ['add', '@angular/pwa', '--skip-confirmation']; + + // Works with scoped registry authentication details + await expectFileNotToExist('public/manifest.webmanifest'); + + await createNpmConfigForAuthentication(); + await ng(...command); + await expectFileToExist('public/manifest.webmanifest'); + await git('clean', '-dxf'); + + // Invalid authentication token + await createNpmConfigForAuthentication(true, true); + await expectToFail(() => ng(...command)); + } finally { + process.env['NPM_CONFIG_REGISTRY'] = originalNpmConfigRegistry; + await git('clean', '-dxf'); + await installWorkspacePackages(); + } +} diff --git a/tests/e2e/tests/commands/add/version-specifier.ts b/tests/e2e/tests/commands/add/version-specifier.ts new file mode 100644 index 000000000000..f88d60a51ec9 --- /dev/null +++ b/tests/e2e/tests/commands/add/version-specifier.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import { appendFile } from 'node:fs/promises'; +import { expectFileToMatch } from '../../../utils/fs'; +import { getActivePackageManager, uninstallPackage } from '../../../utils/packages'; +import { ng } from '../../../utils/process'; +import { isPrereleaseCli } from '../../../utils/project'; + +export default async function () { + // forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/localize'); + + // If using npm, enable the legacy-peer-deps option to allow testing the output behavior of the + // `ng add` command itself and not the behavior of npm which may otherwise fail depending + // on the npm version in use and the version specifier supplied in each test. + if (getActivePackageManager() === 'npm') { + await appendFile('.npmrc', '\nlegacy-peer-deps=true\n'); + } + + const tag = isPrereleaseCli() ? '@next' : ''; + + await ng('add', `@angular/localize${tag}`, '--skip-confirmation'); + await expectFileToMatch('package.json', /@angular\/localize/); + + const output1 = await ng('add', '@angular/localize', '--skip-confirmation'); + assert.match( + output1.stdout, + /Skipping installation: Package already installed/, + 'Installation was not skipped', + ); + + const output2 = await ng('add', '@angular/localize@latest', '--skip-confirmation'); + assert.doesNotMatch( + output2.stdout, + /Skipping installation: Package already installed/, + 'Installation should not have been skipped', + ); + + const output3 = await ng('add', '@angular/localize@19.1.0', '--skip-confirmation'); + assert.doesNotMatch( + output3.stdout, + /Skipping installation: Package already installed/, + 'Installation should not have been skipped', + ); + + const output4 = await ng('add', '@angular/localize@19', '--skip-confirmation'); + assert.match( + output4.stdout, + /Skipping installation: Package already installed/, + 'Installation was not skipped', + ); + + await uninstallPackage('@angular/localize'); +} diff --git a/tests/e2e/tests/commands/add/yarn-env-vars.ts b/tests/e2e/tests/commands/add/yarn-env-vars.ts new file mode 100644 index 000000000000..103a99c04056 --- /dev/null +++ b/tests/e2e/tests/commands/add/yarn-env-vars.ts @@ -0,0 +1,29 @@ +import { expectFileNotToExist, expectFileToExist } from '../../../utils/fs'; +import { getActivePackageManager } from '../../../utils/packages'; +import { git, ng } from '../../../utils/process'; +import { + createNpmConfigForAuthentication, + setNpmEnvVarsForAuthentication, +} from '../../../utils/registry'; + +export default async function () { + // Yarn specific test that tests YARN_ env variables. + // https://classic.yarnpkg.com/en/docs/envvars/ + if (getActivePackageManager() !== 'yarn') { + return; + } + const command = ['add', '@angular/pwa', '--skip-confirmation']; + + // Environment variables only + await expectFileNotToExist('public/manifest.webmanifest'); + setNpmEnvVarsForAuthentication(false, true); + await ng(...command); + await expectFileToExist('public/manifest.webmanifest'); + await git('clean', '-dxf'); + + // Mix of config file and env vars works + await expectFileNotToExist('public/manifest.webmanifest'); + await createNpmConfigForAuthentication(false, true); + await ng(...command); + await expectFileToExist('public/manifest.webmanifest'); +} diff --git a/tests/e2e/tests/commands/additional-properties.ts b/tests/e2e/tests/commands/additional-properties.ts new file mode 100644 index 000000000000..a53006853fca --- /dev/null +++ b/tests/e2e/tests/commands/additional-properties.ts @@ -0,0 +1,31 @@ +import { createDir, rimraf, writeMultipleFiles } from '../../utils/fs'; +import { execAndWaitForOutputToMatch } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; + +export default async function () { + await createDir('example-builder'); + await writeMultipleFiles({ + 'example-builder/package.json': '{ "builders": "./builders.json" }', + 'example-builder/schema.json': + '{ "$schema": "http://json-schema.org/draft-07/schema", "type": "object", "additionalProperties": true }', + 'example-builder/builders.json': + '{ "$schema": "@angular-devkit/architect/src/builders-schema.json", "builders": { "example": { "implementation": "./example", "schema": "./schema.json" } } }', + 'example-builder/example.js': + 'module.exports.default = require("@angular-devkit/architect").createBuilder((options) => { console.log(options); return { success: true }; });', + }); + + await updateJsonFile('angular.json', (json) => { + const appArchitect = json.projects['test-project'].architect; + appArchitect.example = { + builder: './example-builder:example', + }; + }); + + await execAndWaitForOutputToMatch( + 'ng', + ['run', 'test-project:example', '--additional', 'property'], + /Unknown argument: additional/, + ); + + await rimraf('example-builder'); +} diff --git a/tests/e2e/tests/commands/analytics/analytics-enable-disable.ts b/tests/e2e/tests/commands/analytics/analytics-enable-disable.ts new file mode 100644 index 000000000000..94bd8f95edb2 --- /dev/null +++ b/tests/e2e/tests/commands/analytics/analytics-enable-disable.ts @@ -0,0 +1,11 @@ +import assert from 'node:assert'; +import { readFile } from '../../../utils/fs'; +import { ng } from '../../../utils/process'; + +export default async function () { + await ng('analytics', 'enable'); + assert.ok(JSON.parse(await readFile('angular.json')).cli.analytics); + + await ng('analytics', 'disable'); + assert.strictEqual(JSON.parse(await readFile('angular.json')).cli.analytics, false); +} diff --git a/tests/e2e/tests/commands/analytics/analytics-info.ts b/tests/e2e/tests/commands/analytics/analytics-info.ts new file mode 100644 index 000000000000..d92dfd2ffde6 --- /dev/null +++ b/tests/e2e/tests/commands/analytics/analytics-info.ts @@ -0,0 +1,30 @@ +import { execAndWaitForOutputToMatch } from '../../../utils/process'; +import { updateJsonFile } from '../../../utils/project'; + +export default async function () { + // Should be disabled by default. + await configureTest(undefined /** analytics */); + await execAndWaitForOutputToMatch('ng', ['analytics', 'info'], /Effective status: disabled/, { + ...process.env, + NG_FORCE_TTY: '0', // Disable prompts + }); + + await configureTest('1dba0835-38a3-4957-bf34-9974e2df0df3' /** analytics */); + await execAndWaitForOutputToMatch('ng', ['analytics', 'info'], /Effective status: enabled/, { + ...process.env, + NG_FORCE_TTY: '0', // Disable prompts + }); + + await configureTest(false /** analytics */); + await execAndWaitForOutputToMatch('ng', ['analytics', 'info'], /Effective status: disabled/, { + ...process.env, + NG_FORCE_TTY: '0', // Disable prompts + }); +} + +async function configureTest(analytics: false | string | undefined): Promise { + await updateJsonFile('angular.json', (config) => { + config.cli ??= {}; + config.cli.analytics = analytics; + }); +} diff --git a/tests/e2e/tests/commands/analytics/ask-analytics-command.ts b/tests/e2e/tests/commands/analytics/ask-analytics-command.ts new file mode 100644 index 000000000000..ae20bb21f2fa --- /dev/null +++ b/tests/e2e/tests/commands/analytics/ask-analytics-command.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import { execWithEnv } from '../../../utils/process'; +import { mockHome } from '../../../utils/utils'; + +const ANALYTICS_PROMPT = /Would you like to share pseudonymous usage data/; + +export default async function () { + // CLI should prompt for analytics permissions. + await mockHome(async () => { + const { stdout } = await execWithEnv( + 'ng', + ['config'], + { + ...process.env, + NG_FORCE_TTY: '1', + NG_FORCE_AUTOCOMPLETE: 'false', + }, + 'n\n' /* stdin */, + ); + + assert.match(stdout, ANALYTICS_PROMPT, 'CLI did not prompt for analytics permission.'); + }); + + // CLI should skip analytics prompt with `NG_CLI_ANALYTICS=false`. + await mockHome(async () => { + const { stdout } = await execWithEnv('ng', ['config'], { + ...process.env, + NG_FORCE_TTY: '1', + NG_CLI_ANALYTICS: 'false', + NG_FORCE_AUTOCOMPLETE: 'false', + }); + + assert.doesNotMatch( + stdout, + ANALYTICS_PROMPT, + 'CLI prompted for analytics permission when it should be forced off.', + ); + }); + + // CLI should skip analytics prompt during `ng update`. + await mockHome(async () => { + const { stdout } = await execWithEnv('ng', ['update', '--help'], { + ...process.env, + NG_FORCE_TTY: '1', + NG_FORCE_AUTOCOMPLETE: 'false', + }); + + assert.doesNotMatch( + stdout, + ANALYTICS_PROMPT, + 'CLI prompted for analytics permission during an update where it should not have.', + ); + }); +} diff --git a/tests/e2e/tests/commands/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/e2e/tests/commands/config/config-set-serve-port.ts b/tests/e2e/tests/commands/config/config-set-serve-port.ts new file mode 100644 index 000000000000..125d21d7e66f --- /dev/null +++ b/tests/e2e/tests/commands/config/config-set-serve-port.ts @@ -0,0 +1,8 @@ +import { expectFileToMatch } from '../../../utils/fs'; +import { ng } from '../../../utils/process'; + +export default function () { + return Promise.resolve() + .then(() => ng('config', 'projects.test-project.architect.serve.options.port', '1234')) + .then(() => expectFileToMatch('angular.json', /"port": 1234/)); +} diff --git a/tests/e2e/tests/commands/config/config-set.ts b/tests/e2e/tests/commands/config/config-set.ts new file mode 100644 index 000000000000..05fa364bcb37 --- /dev/null +++ b/tests/e2e/tests/commands/config/config-set.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import { ng, silentNg } from '../../../utils/process'; +import { expectToFail } from '../../../utils/utils'; + +export default async function () { + let ngError: Error; + + ngError = await expectToFail(() => silentNg('config', 'cli.warnings.zzzz', 'true')); + assert.match(ngError.message, /Unknown option "zzzz" at "\/cli\/warnings"\./); + + ngError = await expectToFail(() => silentNg('config', 'cli.warnings.zzzz')); + assert.match(ngError.message, /Value cannot be found\./); + + await ng('config', 'cli.warnings.versionMismatch', 'false'); + const { stdout } = await ng('config', 'cli.warnings.versionMismatch'); + assert.match(stdout, /false/); + + await ng('config', 'cli.packageManager', 'yarn'); + const { stdout: stdout2 } = await ng('config', 'cli.packageManager'); + assert.match(stdout2, /yarn/); + + await ng('config', 'schematics', '{"@schematics/angular:component":{"style": "scss"}}'); + const { stdout: stdout3 } = await ng('config', 'schematics.@schematics/angular:component.style'); + assert.match(stdout3, /scss/); + + await ng('config', 'schematics'); + await ng('config', 'schematics', 'undefined'); + await expectToFail(() => ng('config', 'schematics')); +} diff --git a/tests/e2e/tests/commands/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/help/help-option-command.ts b/tests/e2e/tests/commands/help/help-option-command.ts deleted file mode 100644 index 73537f7ed8e2..000000000000 --- a/tests/e2e/tests/commands/help/help-option-command.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {silentNg} from '../../../utils/process'; - - -export default function() { - return Promise.resolve() - .then(() => silentNg('--help', 'build')) - .then(() => process.chdir('/')) - .then(() => silentNg('--help', 'build')); -} diff --git a/tests/e2e/tests/commands/help/help-option.ts b/tests/e2e/tests/commands/help/help-option.ts deleted file mode 100644 index 03b96b5758d9..000000000000 --- a/tests/e2e/tests/commands/help/help-option.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {silentNg} from '../../../utils/process'; - - -export default function() { - return Promise.resolve() - .then(() => silentNg('--help')) - .then(() => process.chdir('/')) - .then(() => silentNg('--help')); -} diff --git a/tests/e2e/tests/commands/help/help.ts b/tests/e2e/tests/commands/help/help.ts deleted file mode 100644 index f326f6a81ff8..000000000000 --- a/tests/e2e/tests/commands/help/help.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {silentNg} from '../../../utils/process'; - - -export default function() { - return Promise.resolve() - .then(() => silentNg('help')) - .then(() => process.chdir('/')) - .then(() => silentNg('help')); -} diff --git a/tests/e2e/tests/commands/new/new-routing.ts b/tests/e2e/tests/commands/new/new-routing.ts deleted file mode 100644 index 1726abad6c21..000000000000 --- a/tests/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', '--single-run')); -} diff --git a/tests/e2e/tests/commands/ng-new-collection.ts b/tests/e2e/tests/commands/ng-new-collection.ts new file mode 100644 index 000000000000..e55c75ee69b4 --- /dev/null +++ b/tests/e2e/tests/commands/ng-new-collection.ts @@ -0,0 +1,19 @@ +import { execAndWaitForOutputToMatch } from '../../utils/process'; + +export default async function () { + const currentDirectory = process.cwd(); + + try { + process.chdir('..'); + + // The below is a way to validate that the `--collection` option is being considered. + await execAndWaitForOutputToMatch( + 'ng', + ['new', '--collection', 'invalid-schematic'], + /Collection "invalid-schematic" cannot be resolved/, + ); + } finally { + // Change directory back + process.chdir(currentDirectory); + } +} diff --git a/tests/e2e/tests/commands/project-cannot-be-determined-by-cwd.ts b/tests/e2e/tests/commands/project-cannot-be-determined-by-cwd.ts new file mode 100644 index 000000000000..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/class.ts b/tests/e2e/tests/generate/class.ts deleted file mode 100644 index 336658a25610..000000000000 --- a/tests/e2e/tests/generate/class.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../utils/process'; -import {expectFileToExist} from '../../utils/fs'; - - -export default function() { - const classDir = join('src', 'app'); - - return ng('generate', 'class', 'test-class', '--spec') - .then(() => expectFileToExist(classDir)) - .then(() => expectFileToExist(join(classDir, 'test-class.ts'))) - .then(() => expectFileToExist(join(classDir, 'test-class.spec.ts'))) - - // Try to run the unit tests. - .then(() => ng('test', '--single-run')); -} diff --git a/tests/e2e/tests/generate/component/component-basic.ts b/tests/e2e/tests/generate/component/component-basic.ts index c0734463fc52..b87e6acad2bf 100644 --- a/tests/e2e/tests/generate/component/component-basic.ts +++ b/tests/e2e/tests/generate/component/component-basic.ts @@ -1,18 +1,20 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToExist} from '../../../utils/fs'; +import { join } from 'node:path'; +import { ng } from '../../../utils/process'; +import { expectFileToExist } from '../../../utils/fs'; +export default function () { + const projectDir = join('src', 'app'); + const componentDir = join(projectDir, 'test-component'); -export default function() { - const componentDir = join('src', 'app', 'test-component'); + return ( + ng('generate', 'component', 'test-component') + .then(() => expectFileToExist(componentDir)) + .then(() => expectFileToExist(join(componentDir, 'test-component.ts'))) + .then(() => expectFileToExist(join(componentDir, 'test-component.spec.ts'))) + .then(() => expectFileToExist(join(componentDir, 'test-component.html'))) + .then(() => expectFileToExist(join(componentDir, 'test-component.css'))) - return ng('generate', 'component', 'test-component') - .then(() => expectFileToExist(componentDir)) - .then(() => expectFileToExist(join(componentDir, 'test-component.component.ts'))) - .then(() => expectFileToExist(join(componentDir, 'test-component.component.spec.ts'))) - .then(() => expectFileToExist(join(componentDir, 'test-component.component.html'))) - .then(() => expectFileToExist(join(componentDir, 'test-component.component.css'))) - - // Try to run the unit tests. - .then(() => ng('test', '--single-run')); + // Try to run the unit tests. + .then(() => ng('test', '--watch=false')) + ); } diff --git a/tests/e2e/tests/generate/component/component-child-dir.ts b/tests/e2e/tests/generate/component/component-child-dir.ts new file mode 100644 index 000000000000..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 index 450d267a312a..63d08af7099d 100644 --- a/tests/e2e/tests/generate/component/component-path-case.ts +++ b/tests/e2e/tests/generate/component/component-path-case.ts @@ -1,26 +1,38 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToExist, createDir} from '../../../utils/fs'; +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); -export default function() { - const rootDir = join('src', 'app', 'Upper-Dir'); - createDir(rootDir); - const componentDir = join(rootDir, 'test-component'); - const componentTwoDir = join(rootDir, 'test-component-two'); + const componentDirectory = join(rootDir, 'test-component'); + const componentTwoDirectory = join(rootDir, 'test-component-two'); - return ng('generate', 'component', 'Upper-Dir/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', 'Upper-Dir/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 { + // Generate a component + await ng('generate', 'component', `${upperDirs}/test-component`); - // Try to run the unit tests. - .then(() => ng('test', '--watch=false')); + // 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/interface.ts b/tests/e2e/tests/generate/interface.ts deleted file mode 100644 index 39b2376ca06c..000000000000 --- a/tests/e2e/tests/generate/interface.ts +++ /dev/null @@ -1,15 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../utils/process'; -import {expectFileToExist} from '../../utils/fs'; - - -export default function() { - const interfaceDir = join('src', 'app'); - - return ng('generate', 'interface', 'test-interface', 'model') - .then(() => expectFileToExist(interfaceDir)) - .then(() => expectFileToExist(join(interfaceDir, 'test-interface.model.ts'))) - - // Try to run the unit tests. - .then(() => ng('test', '--single-run')); -} diff --git a/tests/e2e/tests/generate/library/library-basic.ts b/tests/e2e/tests/generate/library/library-basic.ts new file mode 100644 index 000000000000..efc74694ea00 --- /dev/null +++ b/tests/e2e/tests/generate/library/library-basic.ts @@ -0,0 +1,9 @@ +import { ng } from '../../../utils/process'; +import { useCIChrome } from '../../../utils/project'; + +export default async function () { + await ng('generate', 'library', 'lib-ngmodule', '--no-standalone'); + await useCIChrome('lib-ngmodule', 'projects/lib-ngmodule'); + await ng('test', 'lib-ngmodule', '--no-watch'); + await ng('build', 'lib-ngmodule'); +} diff --git a/tests/e2e/tests/generate/library/library-standalone.ts b/tests/e2e/tests/generate/library/library-standalone.ts new file mode 100644 index 000000000000..0b9754644bf5 --- /dev/null +++ b/tests/e2e/tests/generate/library/library-standalone.ts @@ -0,0 +1,9 @@ +import { ng } from '../../../utils/process'; +import { useCIChrome } from '../../../utils/project'; + +export default async function () { + await ng('generate', 'library', 'lib-standalone', '--standalone'); + await useCIChrome('lib-standalone', 'projects/lib-standalone'); + await ng('test', 'lib-standalone', '--no-watch'); + await ng('build', 'lib-standalone'); +} diff --git a/tests/e2e/tests/generate/module/module-basic.ts b/tests/e2e/tests/generate/module/module-basic.ts index 382aad79f871..18e1a05fedde 100644 --- a/tests/e2e/tests/generate/module/module-basic.ts +++ b/tests/e2e/tests/generate/module/module-basic.ts @@ -1,20 +1,23 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToExist, expectFileToMatch} from '../../../utils/fs'; -import {expectToFail} from '../../../utils/utils'; +import { join } from 'node:path'; +import { ng } from '../../../utils/process'; +import { expectFileToExist, expectFileToMatch } from '../../../utils/fs'; +import { expectToFail } from '../../../utils/utils'; +import { useCIChrome, useCIDefaults } from '../../../utils/project'; +export default async function () { + const projectName = 'test-project-two'; + const moduleDir = `projects/${projectName}/src/app/test`; + await ng('generate', 'application', projectName, '--no-standalone', '--skip-install'); + await useCIDefaults(projectName); + await useCIChrome(projectName, 'projects/test-project-two'); -export default function() { - const moduleDir = join('src', 'app', 'test'); + await ng('generate', 'module', 'test', '--project', projectName); + await expectFileToExist(moduleDir); + await expectFileToExist(join(moduleDir, 'test-module.ts')); + await expectToFail(() => expectFileToExist(join(moduleDir, 'test-routing-module.ts'))); + await expectToFail(() => expectFileToExist(join(moduleDir, 'test.spec.ts'))); + await expectFileToMatch(join(moduleDir, 'test-module.ts'), 'TestModule'); - return ng('generate', 'module', 'test') - .then(() => expectFileToExist(moduleDir)) - .then(() => expectFileToExist(join(moduleDir, 'test.module.ts'))) - .then(() => expectToFail(() => expectFileToExist(join(moduleDir, 'test-routing.module.ts')))) - .then(() => expectToFail(() => expectFileToExist(join(moduleDir, 'test.component.ts')))) - .then(() => expectToFail(() => expectFileToExist(join(moduleDir, 'test.spec.ts')))) - .then(() => expectFileToMatch(join(moduleDir, 'test.module.ts'), 'TestModule')) - - // Try to run the unit tests. - .then(() => ng('test', '--single-run')); + // Try to run the unit tests. + await ng('test', projectName, '--watch=false'); } diff --git a/tests/e2e/tests/generate/module/module-import.ts b/tests/e2e/tests/generate/module/module-import.ts 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/module/module-routing.ts b/tests/e2e/tests/generate/module/module-routing.ts deleted file mode 100644 index 8b98e39d5918..000000000000 --- a/tests/e2e/tests/generate/module/module-routing.ts +++ /dev/null @@ -1,21 +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(() => expectFileToExist(join(moduleDir, 'test.component.ts'))) - .then(() => expectFileToExist(join(moduleDir, 'test.component.spec.ts'))) - .then(() => expectFileToExist(join(moduleDir, 'test.component.html'))) - .then(() => expectFileToExist(join(moduleDir, 'test.component.css'))) - .then(() => expectToFail(() => expectFileToExist(join(moduleDir, 'test.spec.ts')))) - // Try to run the unit tests. - .then(() => ng('test', '--single-run')); -} diff --git a/tests/e2e/tests/generate/pipe.ts b/tests/e2e/tests/generate/pipe.ts deleted file mode 100644 index 125d51fe9505..000000000000 --- a/tests/e2e/tests/generate/pipe.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', '--single-run')); -} 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.ts b/tests/e2e/tests/generate/service.ts deleted file mode 100644 index ec25f3c95621..000000000000 --- a/tests/e2e/tests/generate/service.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', '--single-run')); -} 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/assets.ts b/tests/e2e/tests/misc/assets.ts deleted file mode 100644 index 9dd9504cfac2..000000000000 --- a/tests/e2e/tests/misc/assets.ts +++ /dev/null @@ -1,22 +0,0 @@ -import {writeFile, expectFileToExist, expectFileToMatch} from '../../utils/fs'; -import {ng} from '../../utils/process'; -import {updateJsonFile} from '../../utils/project'; -import {expectToFail} from '../../utils/utils'; - - -export default function() { - return writeFile('src/assets/.file', '') - .then(() => writeFile('src/assets/test.abc', 'hello world')) - .then(() => ng('build')) - .then(() => expectFileToExist('dist/favicon.ico')) - .then(() => expectFileToExist('dist/assets/.file')) - .then(() => expectFileToMatch('dist/assets/test.abc', 'hello world')) - .then(() => expectToFail(() => expectFileToExist('dist/assets/.gitkeep'))) - // doesn't break beta.16 projects - .then(() => updateJsonFile('angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['assets'] = 'assets'; - })) - .then(() => expectFileToExist('dist/assets/.file')) - .then(() => expectFileToMatch('dist/assets/test.abc', 'hello world')); -} diff --git a/tests/e2e/tests/misc/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/coverage.ts b/tests/e2e/tests/misc/coverage.ts deleted file mode 100644 index 6e6242bbb406..000000000000 --- a/tests/e2e/tests/misc/coverage.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {expectFileToExist} from '../../utils/fs'; -import {ng} from '../../utils/process'; - - -export default function() { - return ng('test', '--single-run', '--code-coverage') - .then(() => expectFileToExist('coverage/src/app')) - .then(() => expectFileToExist('coverage/coverage.lcov')); -} 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/lazy-module.ts b/tests/e2e/tests/misc/lazy-module.ts deleted file mode 100644 index d3d33482d877..000000000000 --- a/tests/e2e/tests/misc/lazy-module.ts +++ /dev/null @@ -1,33 +0,0 @@ -import {readdirSync} from 'fs'; -import {oneLine} from 'common-tags'; - -import {ng} from '../../utils/process'; -import {addImportToModule} from '../../utils/ast'; - - -export default function() { - let oldNumberOfFiles = 0; - return Promise.resolve() - .then(() => ng('build')) - .then(() => oldNumberOfFiles = readdirSync('dist').length) - .then(() => ng('generate', 'module', 'lazy', '--routing')) - .then(() => addImportToModule('src/app/app.module.ts', oneLine` - RouterModule.forRoot([{ path: "lazy", loadChildren: "app/lazy/lazy.module#LazyModule" }]), - RouterModule.forRoot([{ path: "lazy1", loadChildren: "./lazy/lazy.module#LazyModule" }]) - `, '@angular/router')) - .then(() => ng('build')) - .then(() => readdirSync('dist').length) - .then(currentNumberOfDistFiles => { - if (oldNumberOfFiles >= currentNumberOfDistFiles) { - throw new Error('A bundle for the lazy module was not created.'); - } - }) - // Check for AoT and lazy routes. - .then(() => ng('build', '--aot')) - .then(() => readdirSync('dist').length) - .then(currentNumberOfDistFiles => { - if (oldNumberOfFiles >= currentNumberOfDistFiles) { - throw new Error('A bundle for the lazy module was not created.'); - } - }); -} 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/proxy.ts b/tests/e2e/tests/misc/proxy.ts deleted file mode 100644 index 26e058c863b3..000000000000 --- a/tests/e2e/tests/misc/proxy.ts +++ /dev/null @@ -1,46 +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 {expectToFail} from '../../utils/utils'; - - -export default function() { - // Create an express app that serves as a proxy. - const app = express(); - const server = http.createServer(app); - server.listen(0); - - app.set('port', server.address().port); - app.get('/api/test', function (req, res) { - res.send('TEST_API_RETURN'); - }); - - const backendHost = 'localhost'; - const backendPort = server.address().port; - const proxyServerUrl = `http://${backendHost}:${backendPort}`; - const proxyConfigFile = 'proxy.config.json'; - const proxyConfig = { - '/api/*': { - target: proxyServerUrl - } - }; - - return Promise.resolve() - .then(() => writeFile(proxyConfigFile, JSON.stringify(proxyConfig, null, 2))) - .then(() => ngServe('--proxy', 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(() => server.close(), (err) => { server.close(); throw err; }) - .then(() => killAllProcesses(), (err) => { killAllProcesses(); throw err; }) - - // A non-existing proxy file should error. - .then(() => expectToFail(() => ng('serve', '--proxy', 'proxy.non-existent.json'))); -} diff --git a/tests/e2e/tests/misc/ssl-default.ts b/tests/e2e/tests/misc/ssl-default.ts deleted file mode 100644 index e16ec54291c9..000000000000 --- a/tests/e2e/tests/misc/ssl-default.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { request } from '../../utils/http'; -import { killAllProcesses } from '../../utils/process'; -import { ngServe } from '../../utils/project'; - - -export default function() { - return Promise.resolve() - .then(() => ngServe('--ssl', 'true')) - .then(() => request('https://localhost:4200/')) - .then(body => { - if (!body.match(/Loading...<\/app-root>/)) { - throw new Error('Response does not match expected value.'); - } - }) - .then(() => killAllProcesses(), (err) => { killAllProcesses(); throw err; }); -} diff --git a/tests/e2e/tests/misc/ssl-with-cert.ts b/tests/e2e/tests/misc/ssl-with-cert.ts deleted file mode 100644 index 3930c91625fb..000000000000 --- a/tests/e2e/tests/misc/ssl-with-cert.ts +++ /dev/null @@ -1,22 +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() { - 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(/Loading...<\/app-root>/)) { - throw new Error('Response does not match expected value.'); - } - }) - .then(() => killAllProcesses(), (err) => { killAllProcesses(); throw err; }); - -} diff --git a/tests/e2e/tests/misc/supported-angular.ts b/tests/e2e/tests/misc/supported-angular.ts new file mode 100644 index 000000000000..d5299485bb7f --- /dev/null +++ b/tests/e2e/tests/misc/supported-angular.ts @@ -0,0 +1,37 @@ +import { SemVer } from 'semver'; +import { getGlobalVariable } from '../../utils/env'; +import { readFile, writeFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { expectToFail } from '../../utils/utils'; + +export default async function () { + if (getGlobalVariable('argv')['ng-snapshots']) { + // The snapshots job won't work correctly because it doesn't use semver for Angular. + return; + } + + const angularCliPkgJson = JSON.parse(await readFile('node_modules/@angular/cli/package.json')); + const cliMajor = new SemVer(angularCliPkgJson.version as string).major; + const angularCorePkgPath = 'node_modules/@angular/core/package.json'; + const originalAngularCorePkgJson = await readFile(angularCorePkgPath); + + // Fake version by writing over the @angular/core version, since that's what the CLI checks. + const fakeCoreVersion = async (newMajor: number) => { + const tmpPkgJson = JSON.parse(originalAngularCorePkgJson); + tmpPkgJson.version = `${newMajor}.0.0`; + await writeFile(angularCorePkgPath, JSON.stringify(tmpPkgJson)); + }; + + // Major should succeed, but we don't need to test it here since it's tested everywhere else. + // Major+1 and -1 should fail architect commands, but allow other commands. + try { + await fakeCoreVersion(cliMajor + 1); + await expectToFail(() => ng('build'), 'Should fail Major+1'); + await ng('version'); + await fakeCoreVersion(cliMajor - 1); + await ng('version'); + } finally { + // Restore the original core package.json. + await writeFile(angularCorePkgPath, originalAngularCorePkgJson); + } +} diff --git a/tests/e2e/tests/misc/target-default-configuration.ts b/tests/e2e/tests/misc/target-default-configuration.ts new file mode 100644 index 000000000000..d5f381b2db97 --- /dev/null +++ b/tests/e2e/tests/misc/target-default-configuration.ts @@ -0,0 +1,35 @@ +import { expectFileToExist } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { expectToFail } from '../../utils/utils'; + +export default async function () { + await updateJsonFile('angular.json', (workspace) => { + const build = workspace.projects['test-project'].architect.build; + build.defaultConfiguration = undefined; + build.options = { + ...build.options, + optimization: false, + buildOptimizer: false, + outputHashing: 'none', + sourceMap: true, + }; + }); + + await ng('build'); + await expectFileToExist('dist/test-project/browser/main.js'); + await expectFileToExist('dist/test-project/browser/main.js.map'); + + // Add new configuration and set "defaultConfiguration" + await updateJsonFile('angular.json', (workspace) => { + const build = workspace.projects['test-project'].architect.build; + build.defaultConfiguration = 'foo'; + build.configurations.foo = { + sourceMap: false, + }; + }); + + await ng('build'); + await expectFileToExist('dist/test-project/browser/main.js'); + await expectToFail(() => expectFileToExist('dist/test-project/browser/main.js.map')); +} diff --git a/tests/e2e/tests/misc/trace-resolution.ts b/tests/e2e/tests/misc/trace-resolution.ts new file mode 100644 index 000000000000..0827223e58a0 --- /dev/null +++ b/tests/e2e/tests/misc/trace-resolution.ts @@ -0,0 +1,22 @@ +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; + +export default async function () { + await updateJsonFile('tsconfig.json', (tsconfig) => { + tsconfig.compilerOptions.traceResolution = true; + }); + + const { stdout: stdoutTraced } = await ng('build', '--configuration=development'); + if (!/Resolving module/.test(stdoutTraced)) { + throw new Error(`Modules resolutions must be printed when 'traceResolution' is enabled.`); + } + + await updateJsonFile('tsconfig.json', (tsconfig) => { + tsconfig.compilerOptions.traceResolution = false; + }); + + const { stdout: stdoutUnTraced } = await ng('build', '--configuration=development'); + if (/Resolving module/.test(stdoutUnTraced)) { + throw new Error(`Modules resolutions must not be printed when 'traceResolution' is disabled.`); + } +} diff --git a/tests/e2e/tests/misc/trusted-types.ts b/tests/e2e/tests/misc/trusted-types.ts new file mode 100644 index 000000000000..21deb03223dd --- /dev/null +++ b/tests/e2e/tests/misc/trusted-types.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import assert from 'node:assert/strict'; +import { replaceInFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { executeBrowserTest } from '../../utils/puppeteer'; + +export default async function () { + // Add lazy route. + await ng('generate', 'component', 'lazy'); + await replaceInFile( + 'src/app/app.routes.ts', + 'routes: Routes = [];', + `routes: Routes = [{path: 'lazy', loadComponent: () => import('./lazy/lazy').then(c => c.Lazy)}];`, + ); + + const testCases = [ + { + aot: false, + csp: `trusted-types angular angular#unsafe-bypass angular#unsafe-jit angular#bundler; require-trusted-types-for 'script';`, + }, + { + aot: true, + csp: `trusted-types angular angular#unsafe-bypass angular#bundler; require-trusted-types-for 'script';`, + }, + ]; + + for (const { aot, csp } of testCases) { + await updateJsonFile('angular.json', (json) => { + const architect = json['projects']['test-project']['architect']; + architect['build']['options']['aot'] = aot; + if (!architect['serve']['options']) architect['serve']['options'] = {}; + architect['serve']['options']['headers'] = { + 'Content-Security-Policy': csp, + }; + }); + + try { + await executeBrowserTest({ + checkFn: async (page) => { + const baseUrl = page.url(); + await page.goto(new URL('/lazy', baseUrl).href); + + await page.waitForSelector('app-lazy p'); + const lazyText = await page.$eval('app-lazy p', (el) => el.textContent); + assert.strictEqual(lazyText, 'lazy works!'); + }, + }); + } catch (error) { + console.error(`Test case AOT ${aot} with CSP header ${csp} failed.`); + throw error; + } + } +} diff --git a/tests/e2e/tests/misc/update-git-clean-subdirectory.ts b/tests/e2e/tests/misc/update-git-clean-subdirectory.ts new file mode 100644 index 000000000000..733a91164019 --- /dev/null +++ b/tests/e2e/tests/misc/update-git-clean-subdirectory.ts @@ -0,0 +1,25 @@ +import { getGlobalVariable } from '../../utils/env'; +import { createDir, writeFile } from '../../utils/fs'; +import { ng, silentGit } from '../../utils/process'; +import { prepareProjectForE2e } from '../../utils/project'; + +export default async function () { + process.chdir(getGlobalVariable('projects-root')); + + await createDir('./subdirectory'); + process.chdir('./subdirectory'); + + await silentGit('init', '.'); + + await ng('new', 'subdirectory-test-project', '--skip-install', '--test-runner', 'karma'); + process.chdir('./subdirectory-test-project'); + await prepareProjectForE2e('subdirectory-test-project'); + + await writeFile('../added.ts', "console.log('created');\n"); + await silentGit('add', '../added.ts'); + + const { stderr } = await ng('update', '@angular/cli'); + if (stderr && stderr.includes('Repository is not clean.')) { + throw new Error('Expected clean repository'); + } +} diff --git a/tests/e2e/tests/misc/update-git-clean.ts b/tests/e2e/tests/misc/update-git-clean.ts new file mode 100644 index 000000000000..c992c695c4e3 --- /dev/null +++ b/tests/e2e/tests/misc/update-git-clean.ts @@ -0,0 +1,12 @@ +import { appendToFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { expectToFail } from '../../utils/utils'; + +export default async function () { + await appendToFile('src/main.ts', "console.log('changed');\n"); + + const { message } = await expectToFail(() => ng('update', '@angular/cli')); + if (!message || !message.includes('Repository is not clean.')) { + throw new Error('Expected unclean repository'); + } +} diff --git a/tests/e2e/tests/misc/version.ts b/tests/e2e/tests/misc/version.ts index d5d5cbd2a2a5..4ad57adc9726 100644 --- a/tests/e2e/tests/misc/version.ts +++ b/tests/e2e/tests/misc/version.ts @@ -1,10 +1,22 @@ -import {deleteFile} from '../../utils/fs'; -import {ng} from '../../utils/process'; +import { deleteFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +export default async function () { + const { stdout: commandOutput } = await ng('version'); -export default function() { - return ng('version') - .then(() => deleteFile('angular-cli.json')) - // doesn't fail on a project with missing angular-cli.json - .then(() => ng('version')); + if (commandOutput.includes(process.versions.node + ' (Unsupported)')) { + throw new Error('Node version should not show unsupported entry'); + } + + if (commandOutput.includes('Warning: The current version of Node ')) { + throw new Error('Node support warning should not be shown'); + } + + // doesn't fail on a project with missing angular.json + await deleteFile('angular.json'); + await ng('version'); + + // Doesn't fail outside a project. + process.chdir('/'); + await ng('version'); } diff --git a/tests/e2e/tests/misc/workspace-verification.ts b/tests/e2e/tests/misc/workspace-verification.ts new file mode 100644 index 000000000000..bf55841a9398 --- /dev/null +++ b/tests/e2e/tests/misc/workspace-verification.ts @@ -0,0 +1,14 @@ +import { deleteFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { expectToFail } from '../../utils/utils'; + +export default function () { + return ( + ng('generate', 'component', 'foo', '--dry-run') + .then(() => deleteFile('angular.json')) + // fails because it needs to be inside a project + // without a workspace file + .then(() => expectToFail(() => ng('generate', 'component', 'foo', '--dry-run'))) + .then(() => ng('version')) + ); +} diff --git a/tests/e2e/tests/mobile/prod-features.ts b/tests/e2e/tests/mobile/prod-features.ts deleted file mode 100644 index e649c9a53f69..000000000000 --- a/tests/e2e/tests/mobile/prod-features.ts +++ /dev/null @@ -1,20 +0,0 @@ -import {isMobileTest, expectToFail} from '../../utils/utils'; -import {expectFileToMatch, expectFileToExist} from '../../utils/fs'; - - -export default function() { - if (!isMobileTest()) { - return; - } - - return Promise.resolve() - // Service Worker - .then(() => expectToFail(() => expectFileToMatch('dist/index.html', - 'if (\'serviceWorker\' in navigator) {'))) - .then(() => expectToFail(() => expectFileToExist('dist/worker.js'))) - - // Asynchronous bundle - .then(() => expectToFail(() => expectFileToMatch('dist/index.html', - ''))) - .then(() => expectToFail(() => expectFileToExist('dist/app-concat.js'))); -} diff --git a/tests/e2e/tests/packages/webpack/test.ts b/tests/e2e/tests/packages/webpack/test.ts deleted file mode 100644 index bb68cd14754e..000000000000 --- a/tests/e2e/tests/packages/webpack/test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {normalize} from 'path'; -import {createProjectFromAsset} from '../../../utils/assets'; -import {exec} from '../../../utils/process'; -import {expectFileSizeToBeUnder} from '../../../utils/fs'; - - -export default function(skipCleaning: () => void) { - return Promise.resolve() - .then(() => createProjectFromAsset('webpack/test-app')) - .then(() => exec(normalize('node_modules/.bin/webpack'), '-p')) - .then(() => expectFileSizeToBeUnder('dist/app.main.js', 420000)) - .then(() => expectFileSizeToBeUnder('dist/0.app.main.js', 40000)) - .then(() => skipCleaning()); -} diff --git a/tests/e2e/tests/packages/webpack/weird.ts b/tests/e2e/tests/packages/webpack/weird.ts deleted file mode 100644 index fb95fbabf47e..000000000000 --- a/tests/e2e/tests/packages/webpack/weird.ts +++ /dev/null @@ -1,33 +0,0 @@ -import {normalize} from 'path'; - -import {createProjectFromAsset} from '../../../utils/assets'; -import {exec} from '../../../utils/process'; -import {updateJsonFile} from '../../../utils/project'; -import {expectFileSizeToBeUnder, expectFileToExist} from '../../../utils/fs'; -import {expectToFail} from '../../../utils/utils'; - - -export default function(skipCleaning: () => void) { - return Promise.resolve() - .then(() => createProjectFromAsset('webpack/test-app-weird')) - .then(() => exec(normalize('node_modules/.bin/webpack'), '-p')) - .then(() => expectFileToExist('dist/app.main.js')) - .then(() => expectFileToExist('dist/0.app.main.js')) - .then(() => expectFileToExist('dist/1.app.main.js')) - .then(() => expectFileToExist('dist/2.app.main.js')) - .then(() => expectFileSizeToBeUnder('dist/app.main.js', 400000)) - .then(() => expectFileSizeToBeUnder('dist/0.app.main.js', 40000)) - - // Skip code generation and rebuild. - .then(() => updateJsonFile('aotplugin.config.json', json => { - json['skipCodeGeneration'] = true; - })) - .then(() => exec(normalize('node_modules/.bin/webpack'), '-p')) - .then(() => expectFileToExist('dist/app.main.js')) - .then(() => expectFileToExist('dist/0.app.main.js')) - .then(() => expectFileToExist('dist/1.app.main.js')) - .then(() => expectFileToExist('dist/2.app.main.js')) - .then(() => expectToFail(() => expectFileSizeToBeUnder('dist/app.main.js', 400000))) - .then(() => expectFileSizeToBeUnder('dist/0.app.main.js', 40000)) - .then(() => skipCleaning()); -} diff --git a/tests/e2e/tests/schematics_cli/basic.ts b/tests/e2e/tests/schematics_cli/basic.ts new file mode 100644 index 000000000000..62128e1e32db --- /dev/null +++ b/tests/e2e/tests/schematics_cli/basic.ts @@ -0,0 +1,37 @@ +import { join } from 'node:path'; +import { getGlobalVariable } from '../../utils/env'; +import { exec, execAndWaitForOutputToMatch, silentNpm } from '../../utils/process'; +import { rimraf } from '../../utils/fs'; + +export default async function () { + // setup + const argv = getGlobalVariable('argv'); + if (argv.noglobal) { + return; + } + + await silentNpm('install', '-g', '@angular-devkit/schematics-cli'); + await exec(process.platform.startsWith('win') ? 'where' : 'which', 'schematics'); + + const startCwd = process.cwd(); + const schematicPath = join(startCwd, 'test-schematic'); + + try { + // create blank schematic + await exec('schematics', 'schematic', '--name', 'test-schematic'); + + process.chdir(join(startCwd, 'test-schematic')); + await execAndWaitForOutputToMatch( + 'schematics', + ['.:', '--list-schematics'], + /my-full-schematic/, + ); + } finally { + // restore path + process.chdir(startCwd); + await Promise.all([ + rimraf(schematicPath), + silentNpm('uninstall', '-g', '@angular-devkit/schematics-cli'), + ]); + } +} diff --git a/tests/e2e/tests/schematics_cli/blank-test.ts b/tests/e2e/tests/schematics_cli/blank-test.ts new file mode 100644 index 000000000000..4087f7476db9 --- /dev/null +++ b/tests/e2e/tests/schematics_cli/blank-test.ts @@ -0,0 +1,34 @@ +import { join } from 'node:path'; +import { getGlobalVariable } from '../../utils/env'; +import { exec, silentNpm } from '../../utils/process'; +import { rimraf } from '../../utils/fs'; + +export default async function () { + // setup + const argv = getGlobalVariable('argv'); + if (argv.noglobal) { + return; + } + + await silentNpm('install', '-g', '@angular-devkit/schematics-cli'); + await exec(process.platform.startsWith('win') ? 'where' : 'which', 'schematics'); + + const startCwd = process.cwd(); + const schematicPath = join(startCwd, 'test-schematic'); + + try { + // create schematic + await exec('schematics', 'blank', '--name', 'test-schematic'); + + process.chdir(schematicPath); + + await silentNpm('test'); + } finally { + // restore path + process.chdir(startCwd); + await Promise.all([ + rimraf(schematicPath), + silentNpm('uninstall', '-g', '@angular-devkit/schematics-cli'), + ]); + } +} diff --git a/tests/e2e/tests/schematics_cli/schematic-test.ts b/tests/e2e/tests/schematics_cli/schematic-test.ts new file mode 100644 index 000000000000..7d7d26e7304b --- /dev/null +++ b/tests/e2e/tests/schematics_cli/schematic-test.ts @@ -0,0 +1,34 @@ +import { join } from 'node:path'; +import { getGlobalVariable } from '../../utils/env'; +import { exec, silentNpm } from '../../utils/process'; +import { rimraf } from '../../utils/fs'; + +export default async function () { + // setup + const argv = getGlobalVariable('argv'); + if (argv.noglobal) { + return; + } + + await silentNpm('install', '-g', '@angular-devkit/schematics-cli'); + await exec(process.platform.startsWith('win') ? 'where' : 'which', 'schematics'); + + const startCwd = process.cwd(); + const schematicPath = join(startCwd, 'test-schematic'); + + try { + // create schematic + await exec('schematics', 'schematic', '--name', 'test-schematic'); + + process.chdir(schematicPath); + + await silentNpm('test'); + } finally { + // restore path + process.chdir(startCwd); + await Promise.all([ + rimraf(schematicPath), + silentNpm('uninstall', '-g', '@angular-devkit/schematics-cli'), + ]); + } +} diff --git a/tests/e2e/tests/test/e2e.ts b/tests/e2e/tests/test/e2e.ts deleted file mode 100644 index ca66091906c7..000000000000 --- a/tests/e2e/tests/test/e2e.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {ng, killAllProcesses} from '../../utils/process'; -import {expectToFail} from '../../utils/utils'; -import {ngServe} from '../../utils/project'; - - -function _runServeAndE2e(...args: string[]) { - return ngServe(...args) - .then(() => ng('e2e')) - .then(() => killAllProcesses(), (err: any) => { - killAllProcesses(); - throw err; - }); -} - -export default function() { - // This is supposed to fail without serving first... - return expectToFail(() => ng('e2e')) - // These should work. - .then(() => _runServeAndE2e()) - .then(() => _runServeAndE2e('--prod')) - .then(() => _runServeAndE2e('--aot')) - .then(() => _runServeAndE2e('--aot', '--prod')); -} 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/lint.ts b/tests/e2e/tests/test/lint.ts deleted file mode 100644 index 55242cc11cd2..000000000000 --- a/tests/e2e/tests/test/lint.ts +++ /dev/null @@ -1,6 +0,0 @@ -import {ng} from '../../utils/process'; - - -export default function() { - return ng('lint'); -} diff --git a/tests/e2e/tests/test/test-assets.ts b/tests/e2e/tests/test/test-assets.ts deleted file mode 100644 index 195b05e47c35..000000000000 --- a/tests/e2e/tests/test/test-assets.ts +++ /dev/null @@ -1,49 +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 () { - return Promise.resolve() - .then(() => writeMultipleFiles({ - 'src/assets/file.txt': 'assets-folder-content', - 'src/file.txt': 'file-content', - // Not using `async()` in tests as it seemed to swallow `fetch()` errors - 'src/app/app.component.spec.ts': stripIndent` - describe('Test Runner', () => { - const fetch = global['fetch']; - it('should serve files in assets folder', (done) => { - fetch('/assets/file.txt') - .then(response => response.text()) - .then(fileText => { - expect(fileText).toMatch('assets-folder-content'); - done(); - }); - }); - it('should serve files explicitly added to assets array', (done) => { - fetch('/file.txt') - .then(response => response.text()) - .then(fileText => { - expect(fileText).toMatch('file-content'); - done(); - }); - }); - }); - ` - })) - // Test failure condition (no assets in angular-cli.json) - .then(() => updateJsonFile('angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['assets'] = []; - })) - .then(() => expectToFail(() => ng('test', '--single-run'), - 'Should fail because the assets to serve were not in the angular-cli config')) - // Test passing condition (assets are included) - .then(() => updateJsonFile('angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['assets'] = ['assets', 'file.txt']; - })) - .then(() => ng('test', '--single-run')); -} diff --git a/tests/e2e/tests/test/test-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 index 2e4d581d1bb5..953ed52cfbd7 100644 --- a/tests/e2e/tests/test/test-scripts.ts +++ b/tests/e2e/tests/test/test-scripts.ts @@ -1,71 +1,71 @@ +import { getGlobalVariable } from '../../utils/env'; import { writeMultipleFiles } from '../../utils/fs'; import { ng } from '../../utils/process'; import { updateJsonFile } from '../../utils/project'; import { expectToFail } from '../../utils/utils'; -import { stripIndent } from 'common-tags'; -export default function () { - return Promise.resolve() - .then(() => ng('test', '--watch=false')) - // prepare global scripts test files - .then(() => writeMultipleFiles({ - 'src/string-script.js': `stringScriptGlobal = 'string-scripts.js';`, - 'src/input-script.js': `inputScriptGlobal = 'input-scripts.js';`, - 'src/typings.d.ts': stripIndent` - declare var stringScriptGlobal: any; - declare var inputScriptGlobal: any; - `, - 'src/app/app.component.ts': stripIndent` - import { Component } from '@angular/core'; +export default async function () { + // prepare global scripts test files + await writeMultipleFiles({ + 'src/string-script.js': `globalThis.stringScriptGlobal = 'string-scripts.js';`, + 'src/input-script.js': `globalThis.inputScriptGlobal = 'input-scripts.js';`, + 'src/typings.d.ts': ` + declare var stringScriptGlobal: any; + declare var inputScriptGlobal: any; + `, + 'src/app/app.ts': ` + import { Component } from '@angular/core'; - @Component({ selector: 'app-root', template: '' }) - export class AppComponent { - stringScriptGlobalProp = stringScriptGlobal; - inputScriptGlobalProp = inputScriptGlobal; - } - `, - 'src/app/app.component.spec.ts': stripIndent` - import { TestBed, async } from '@angular/core/testing'; - import { AppComponent } from './app.component'; + @Component({ selector: 'app-root', template: '', standalone: false }) + export class App { + stringScriptGlobalProp = stringScriptGlobal; + inputScriptGlobalProp = inputScriptGlobal; + } + `, + 'src/app/app.spec.ts': ` + import { TestBed } from '@angular/core/testing'; + import { App } from './app'; - describe('AppComponent', () => { - beforeEach(() => { - TestBed.configureTestingModule({ declarations: [ AppComponent ] }); - TestBed.compileComponents(); - }); + describe('App', () => { + beforeEach(() => TestBed.configureTestingModule({ + declarations: [App] + })); - it('should have access to string-script.js', async(() => { - let app = TestBed.createComponent(AppComponent).debugElement.componentInstance; - expect(app.stringScriptGlobalProp).toEqual('string-scripts.js'); - })); + it('should have access to string-script.js', () => { + let app = TestBed.createComponent(App).debugElement.componentInstance; + expect(app.stringScriptGlobalProp).toEqual('string-scripts.js'); + }); - it('should have access to input-script.js', async(() => { - let app = TestBed.createComponent(AppComponent).debugElement.componentInstance; - expect(app.inputScriptGlobalProp).toEqual('input-scripts.js'); - })); + it('should have access to input-script.js', () => { + let app = TestBed.createComponent(App).debugElement.componentInstance; + expect(app.inputScriptGlobalProp).toEqual('input-scripts.js'); }); + }); - describe('Spec', () => { - it('should have access to string-script.js', async(() => { - expect(stringScriptGlobal).toBe('string-scripts.js'); - })); + describe('Spec', () => { + it('should have access to string-script.js', () => { + expect(stringScriptGlobal).toBe('string-scripts.js'); + }); - it('should have access to input-script.js', async(() => { - expect(inputScriptGlobal).toBe('input-scripts.js'); - })); + it('should have access to input-script.js', () => { + expect(inputScriptGlobal).toBe('input-scripts.js'); }); - ` - })) - // should fail because the global scripts were not added to scripts array - .then(() => expectToFail(() => ng('test', '--single-run'))) - .then(() => updateJsonFile('angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['scripts'] = [ - 'string-script.js', - { input: 'input-script.js' } - ]; - })) - // should pass now - .then(() => ng('test', '--single-run')); -} + }); + `, + }); + // should fail because the global scripts were not added to scripts array + await expectToFail(() => ng('test', '--watch=false')); + + const isWebpack = !getGlobalVariable('argv')['esbuild']; + await updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + appArchitect[isWebpack ? 'test' : 'build'].options.scripts = [ + { input: 'src/string-script.js' }, + { input: 'src/input-script.js' }, + ]; + }); + + // should pass now + await ng('test', '--watch=false'); +} diff --git a/tests/e2e/tests/test/test-sourcemap.ts b/tests/e2e/tests/test/test-sourcemap.ts new file mode 100644 index 000000000000..5d4e668c54b6 --- /dev/null +++ b/tests/e2e/tests/test/test-sourcemap.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert'; +import { writeFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { assertIsError } from '../../utils/utils'; +import { updateJsonFile } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + const isWebpack = !getGlobalVariable('argv')['esbuild']; + + await writeFile( + 'src/app/app.spec.ts', + ` + it('should fail', () => { + expect(undefined).toBeTruthy(); + }); + `, + ); + + // when sourcemaps are 'on' the stacktrace will point to the spec.ts file. + await updateJsonFile('angular.json', (configJson) => { + const appArchitect = configJson.projects['test-project'].architect; + if (isWebpack) { + appArchitect['test'].options.sourceMap = true; + } else { + appArchitect['build'].configurations.development.sourceMap = true; + } + }); + try { + await ng('test', '--no-watch'); + throw new Error('ng test should have failed.'); + } catch (error) { + assertIsError(error); + assert.match(error.message, /\(src\/app\/app\.spec\.ts:3:27/); + assert.doesNotMatch(error.message, /webpack/); + } + + // when sourcemaps are 'off' the stacktrace won't point to the spec.ts file. + await updateJsonFile('angular.json', (configJson) => { + const appArchitect = configJson.projects['test-project'].architect; + if (isWebpack) { + appArchitect['test'].options.sourceMap = false; + } else { + appArchitect['build'].configurations.development.sourceMap = false; + } + }); + try { + await ng('test', '--no-watch'); + throw new Error('ng test should have failed.'); + } catch (error) { + assertIsError(error); + assert.match(error.message, /main\.js/); + } +} diff --git a/tests/e2e/tests/test/test.ts b/tests/e2e/tests/test/test.ts deleted file mode 100644 index ed4ea6c5f0d4..000000000000 --- a/tests/e2e/tests/test/test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { ng } from '../../utils/process'; - -export default function () { - // make sure both --watch=false and --single-run work - return ng('test', '--single-run') - .then(() => ng('test', '--watch=false')); -} diff --git a/tests/e2e/tests/third-party/bootstrap.ts b/tests/e2e/tests/third-party/bootstrap.ts deleted file mode 100644 index 23f922440970..000000000000 --- a/tests/e2e/tests/third-party/bootstrap.ts +++ /dev/null @@ -1,30 +0,0 @@ -import {npm, ng} from '../../utils/process'; -import {updateJsonFile} from '../../utils/project'; -import {expectFileToMatch} from '../../utils/fs'; -import {oneLineTrim} from 'common-tags'; - - -export default function() { - return Promise.resolve() - .then(() => npm('install', 'bootstrap@next')) - .then(() => updateJsonFile('angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['styles'].push('../node_modules/bootstrap/dist/css/bootstrap.css'); - app['scripts'].push( - '../node_modules/jquery/dist/jquery.js', - '../node_modules/tether/dist/js/tether.js', - '../node_modules/bootstrap/dist/js/bootstrap.js' - ); - })) - .then(() => ng('build')) - .then(() => expectFileToMatch('dist/scripts.bundle.js', '* jQuery JavaScript')) - .then(() => expectFileToMatch('dist/scripts.bundle.js', '/*! tether ')) - .then(() => expectFileToMatch('dist/scripts.bundle.js', '* Bootstrap')) - .then(() => expectFileToMatch('dist/styles.bundle.css', '* Bootstrap')) - .then(() => expectFileToMatch('dist/index.html', oneLineTrim` - - - - - `)); -} diff --git a/tests/e2e/tests/update/update-application-builder.ts b/tests/e2e/tests/update/update-application-builder.ts new file mode 100644 index 000000000000..2769fe943138 --- /dev/null +++ b/tests/e2e/tests/update/update-application-builder.ts @@ -0,0 +1,36 @@ +import { match } from 'node:assert'; +import { createProjectFromAsset } from '../../utils/assets'; +import { + expectFileMatchToExist, + expectFileNotToExist, + expectFileToExist, + expectFileToMatch, +} from '../../utils/fs'; +import { execAndWaitForOutputToMatch, ng, noSilentNg } from '../../utils/process'; +import { findFreePort } from '../../utils/network'; + +export default async function () { + await createProjectFromAsset('ssr-project-webpack', false, false); + await ng('update', `@angular/cli`, '--name=use-application-builder'); + + await Promise.all([ + expectFileNotToExist('tsconfig.server.json'), + expectFileToMatch('tsconfig.json', 'esModuleInterop'), + expectFileToMatch('src/server.ts', 'import.meta.url'), + ]); + + // Verify project now creates bundles + await noSilentNg('build', '--configuration=production'); + + await Promise.all([ + expectFileToExist('dist/ssr-project-webpack/server/server.mjs'), + expectFileMatchToExist('dist/ssr-project-webpack/browser', /main-[a-zA-Z0-9]{8}\.js/), + ]); + + // Verify that the app runs + const port = await findFreePort(); + await execAndWaitForOutputToMatch('ng', ['serve', '--port', String(port)], /complete\./); + const response = await fetch(`http://localhost:${port}/`); + const text = await response.text(); + match(text, /app is running!/); +} diff --git a/tests/e2e/tests/update/update-multiple-versions.ts b/tests/e2e/tests/update/update-multiple-versions.ts new file mode 100644 index 000000000000..c9070b1a090b --- /dev/null +++ b/tests/e2e/tests/update/update-multiple-versions.ts @@ -0,0 +1,43 @@ +import { createProjectFromAsset } from '../../utils/assets'; +import { setRegistry } from '../../utils/packages'; +import { ng } from '../../utils/process'; +import { isPrereleaseCli } from '../../utils/project'; +import { expectToFail } from '../../utils/utils'; + +export default async function () { + let restoreRegistry: (() => Promise) | undefined; + try { + restoreRegistry = await createProjectFromAsset('20.0-project', true); + await setRegistry(true); + + const extraArgs = ['--force']; + if (isPrereleaseCli()) { + extraArgs.push('--next'); + } + + // TODO(alanagius): investigate how to re-enable this. This is failing but it's correct since we are using the public registry. + // Update Angular from v13 to 14 + // const { stdout } = await ng('update', ...extraArgs); + // if (!/@angular\/core\s+13\.\d\.\d+ -> 14\.\d\.\d+\s+ng update @angular\/core@14/.test(stdout)) { + // // @angular/core 13.x.x -> 14.x.x ng update @angular/core@14 + // throw new Error( + // `Output didn't match "@angular/core 13.x.x -> 14.x.x ng update @angular/core@14". OUTPUT: \n` + + // stdout, + // ); + // } + + const { message } = await expectToFail(() => ng('update', '@angular/core', ...extraArgs)); + if ( + !message.includes( + `Updating multiple major versions of '@angular/core' at once is not supported`, + ) + ) { + throw new Error( + `Expected error message to include "Updating multiple major versions of '@angular/core' at once is not supported" but didn't. OUTPUT: \n` + + message, + ); + } + } finally { + await restoreRegistry?.(); + } +} diff --git a/tests/e2e/tests/update/update-pnpm-workspace.ts b/tests/e2e/tests/update/update-pnpm-workspace.ts new file mode 100644 index 000000000000..29aff7bff42a --- /dev/null +++ b/tests/e2e/tests/update/update-pnpm-workspace.ts @@ -0,0 +1,33 @@ +import { createProjectFromAsset } from '../../utils/assets'; +import { readFile, writeFile } from '../../utils/fs'; +import { getActivePackageManager } from '../../utils/packages'; +import { ng } from '../../utils/process'; + +export default async function () { + if (getActivePackageManager() !== 'pnpm') { + return; + } + + let restoreRegistry: (() => Promise) | undefined; + + try { + // Setup project from older asset using the public registry + restoreRegistry = await createProjectFromAsset('20.0-project', true); + + // Create pnpm-workspace.yaml inside the project directory + await writeFile('pnpm-workspace.yaml', "packages:\n - '.'\n"); + + // Run ng update on @angular/cli to trigger the update from version 20 to the next major version + await ng('update', '@angular/cli@21', '@angular/core@21'); + + // Verify that the pnpm lockfile does not contain references to the temporary package directory + const lockfileContent = await readFile('pnpm-lock.yaml'); + if (lockfileContent.includes('angular-cli-tmp-packages-')) { + throw new Error( + 'pnpm-lock.yaml contains reference to temporary package directory, isolation failed!', + ); + } + } finally { + await restoreRegistry?.(); + } +} diff --git a/tests/e2e/tests/update/update-secure-registry.ts b/tests/e2e/tests/update/update-secure-registry.ts new file mode 100644 index 000000000000..47a9db657cd8 --- /dev/null +++ b/tests/e2e/tests/update/update-secure-registry.ts @@ -0,0 +1,39 @@ +import { exec, ng } from '../../utils/process'; +import { createNpmConfigForAuthentication } from '../../utils/registry'; +import { expectToFail } from '../../utils/utils'; +import { isPrereleaseCli } from '../../utils/project'; +import { getActivePackageManager } from '../../utils/packages'; +import assert from 'node:assert'; + +export default async function () { + // The environment variable has priority over the .npmrc + delete process.env['NPM_CONFIG_REGISTRY']; + const worksMessage = 'We analyzed your package.json'; + + const extraArgs: string[] = []; + if (isPrereleaseCli()) { + extraArgs.push('--next'); + } + + // Valid authentication token + + await createNpmConfigForAuthentication(); + const { stdout: stdout2 } = await ng('update', ...extraArgs); + if (!stdout2.includes(worksMessage)) { + throw new Error(`Expected stdout to contain "${worksMessage}"`); + } + + // Invalid authentication token + + await createNpmConfigForAuthentication(true, true); + await expectToFail(() => ng('update', ...extraArgs)); + + if (getActivePackageManager() === 'yarn') { + // When running `ng update` using yarn (`yarn ng update`), yarn will set the `npm_config_registry` env variable to `https://registry.yarnpkg.com` + // Validate the registry in the RC is used. + await createNpmConfigForAuthentication(true, true); + + const error = await expectToFail(() => exec('yarn', 'ng', 'update', ...extraArgs)); + assert.match(error.message, /not allowed to access package|status code 403/); + } +} diff --git a/tests/e2e/tests/update/update.ts b/tests/e2e/tests/update/update.ts new file mode 100644 index 000000000000..fd8663503ecc --- /dev/null +++ b/tests/e2e/tests/update/update.ts @@ -0,0 +1,94 @@ +import { appendFile } from 'node:fs/promises'; +import { createProjectFromAsset } from '../../utils/assets'; +import { expectFileMatchToExist } from '../../utils/fs'; +import { getActivePackageManager } from '../../utils/packages'; +import { ng, noSilentNg } from '../../utils/process'; +import { isPrereleaseCli, useCIChrome, useCIDefaults, getNgCLIVersion } from '../../utils/project'; +import { executeBrowserTest } from '../../utils/puppeteer'; + +export default async function () { + let restoreRegistry: (() => Promise) | undefined; + + try { + // We need to use the public registry because in the local NPM server we don't have + // older versions @angular/cli packages which would cause `npm install` during `ng update` to fail. + restoreRegistry = await createProjectFromAsset('20.0-project', true); + + // CLI project version + const cliMajorProjectVersion = 20; + + // If using npm, enable legacy peer deps mode to avoid defects in npm 7+'s peer dependency resolution + // Example error where 11.2.14 satisfies the SemVer range ^11.0.0 but still fails: + // npm ERR! Conflicting peer dependency: @angular/compiler-cli@11.2.14 + // npm ERR! node_modules/@angular/compiler-cli + // npm ERR! peer @angular/compiler-cli@"^11.0.0 || ^11.2.0-next" from @angular-devkit/build-angular@0.1102.19 + // npm ERR! node_modules/@angular-devkit/build-angular + // npm ERR! dev @angular-devkit/build-angular@"~0.1102.19" from the root project + if (getActivePackageManager() === 'npm') { + await appendFile('.npmrc', '\nlegacy-peer-deps=true'); + } + + // CLI current version. + const cliMajorVersion = getNgCLIVersion().major; + + for (let version = cliMajorProjectVersion + 1; version < cliMajorVersion; version++) { + // Run all the migrations until the current build major version - 1. + // Example: when the project is using CLI version 10 and the build CLI version is 14. + // We will run the following migrations: + // - 10 -> 11 + // - 11 -> 12 + // - 12 -> 13 + const { stdout } = await ng('update', `@angular/cli@${version}`, `@angular/core@${version}`); + if ( + !stdout.includes("Executing migrations of package '@angular/cli'") && + !stdout.includes("Optional migrations of package '@angular/cli'") + ) { + throw new Error('Update did not execute migrations for @angular/cli. OUTPUT: \n' + stdout); + } + if (!stdout.includes("Executing migrations of package '@angular/core'")) { + throw new Error('Update did not execute migrations for @angular/core. OUTPUT: \n' + stdout); + } + } + } finally { + await restoreRegistry?.(); + } + + // Update Angular current build + const extraUpdateArgs = isPrereleaseCli() ? ['--next', '--force'] : []; + + // For the latest/next release we purposely don't run `ng update @angular/core`. + + // During a major release when the branch version is bumped from `12.0.0-rc.x` to `12.0.0` there would be a period were in + // the local NPM registry `@angular/cli@latest` will point to `12.0.0`, but on the public NPM repository `@angular/core@latest` will be `11.2.x`. + + // This causes `ng update @angular/core` to fail because of mismatching peer dependencies. + + // The reason for this is because of our bumping and release strategy. When we release a major version on NPM we don't tag it + // `@latest` right away, but we wait for all teams to release their packages before doing so. While this is good because all team + // packages gets tagged with `@latest` at the same time. This is problematic for our CI, since we test against the public NPM repo and are dependent on tags. + + // NB: `ng update @angular/cli` will still cause `@angular/core` packages to be updated therefore we still test updating the core package without running the command. + + await ng('update', '@angular/cli', ...extraUpdateArgs); + + // Setup testing to use CI Chrome. + await useCIChrome('twenty-project', './'); + await useCIDefaults('twenty-project'); + + // Run CLI commands. + await ng('generate', 'component', 'my-comp'); + await ng('test', '--watch=false'); + + await executeBrowserTest({ + configuration: 'production', + expectedTitleText: 'Hello, twenty-project', + }); + await executeBrowserTest({ + configuration: 'development', + expectedTitleText: 'Hello, twenty-project', + }); + + // Verify project now creates bundles + await noSilentNg('build', '--configuration=production'); + await expectFileMatchToExist('dist/twenty-project/browser', /main-[a-zA-Z0-9]{8}\.js/); +} diff --git a/tests/e2e/tests/vite/reuse-dep-optimization-cache.ts b/tests/e2e/tests/vite/reuse-dep-optimization-cache.ts new file mode 100644 index 000000000000..07be3f2a911b --- /dev/null +++ b/tests/e2e/tests/vite/reuse-dep-optimization-cache.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert'; +import { findFreePort } from '../../utils/network'; +import { execAndWaitForOutputToMatch, killAllProcesses, ng } from '../../utils/process'; + +export default async function () { + await ng('cache', 'clean'); + await ng('cache', 'on'); + + const port = await findFreePort(); + await execAndWaitForOutputToMatch( + 'ng', + ['serve', '--port', `${port}`], + /dependencies optimized/, + // Use CI:0 to force caching + { ...process.env, DEBUG: 'vite:deps', CI: '0', NO_COLOR: 'true' }, + ); + + const response = await fetch(`http://localhost:${port}/main.js`); + + assert(response.ok, `Expected 'response.ok' to be 'true'.`); + + // Terminate the dev-server + await killAllProcesses(); + + await execAndWaitForOutputToMatch( + 'ng', + ['serve', '--port=0'], + /Hash is consistent\. Skipping/, + // Use CI:0 to force caching + { ...process.env, DEBUG: 'vite:deps', CI: '0', NO_COLOR: 'true' }, + ); +} diff --git a/tests/e2e/tests/vite/ssr-base-href.ts b/tests/e2e/tests/vite/ssr-base-href.ts new file mode 100644 index 000000000000..140f2582689a --- /dev/null +++ b/tests/e2e/tests/vite/ssr-base-href.ts @@ -0,0 +1,48 @@ +import assert from 'node:assert'; +import { ng } from '../../utils/process'; +import { replaceInFile } from '../../utils/fs'; +import { installWorkspacePackages, uninstallPackage } from '../../utils/packages'; +import { ngServe, updateJsonFile, useSha } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + await updateJsonFile('angular.json', (json) => { + json.projects['test-project'].architect.build.options['baseHref'] = '/base'; + }); + + await replaceInFile( + 'src/server.ts', + /express\(\);/, + `express(); + + app.use('/ping', (req, res) => { + return res.json({ pong: true }); + });`, + ); + + const port = await ngServe(); + + // Angular application and bundled should be affected by baseHref + await matchResponse(`http://localhost:${port}/base`, /ng-server-context/); + await matchResponse(`http://localhost:${port}/base/main.js`, /App/); + + // Server endpoint should not be affected by baseHref + await matchResponse(`http://localhost:${port}/ping`, /pong/); +} + +async function matchResponse(url: string, match: RegExp): Promise { + const response = await fetch(url); + const text = await response.text(); + + assert.match(text, match); +} diff --git a/tests/e2e/tests/vite/ssr-default.ts b/tests/e2e/tests/vite/ssr-default.ts new file mode 100644 index 000000000000..8b64a4b30f67 --- /dev/null +++ b/tests/e2e/tests/vite/ssr-default.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert'; +import { ng } from '../../utils/process'; +import { installWorkspacePackages, uninstallPackage } from '../../utils/packages'; +import { ngServe, useSha } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Enable caching to test real development workflow. + await ng('cache', 'clean'); + await ng('cache', 'on'); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + const port = await ngServe(); + + // Verify the server is running and the API response is correct. + await validateResponse('/main.js', /bootstrapApplication/); + await validateResponse('/', /Hello,/); + await validateResponse('/unknown', /Cannot GET/, 404); + + async function validateResponse(pathname: string, match: RegExp, status = 200): Promise { + const response = await fetch(new URL(pathname, `http://localhost:${port}`)); + const text = await response.text(); + assert.match(text, match); + assert.equal(response.status, status); + } +} diff --git a/tests/e2e/tests/vite/ssr-entry-express.ts b/tests/e2e/tests/vite/ssr-entry-express.ts new file mode 100644 index 000000000000..11cf671becbf --- /dev/null +++ b/tests/e2e/tests/vite/ssr-entry-express.ts @@ -0,0 +1,130 @@ +import assert from 'node:assert'; +import { setTimeout } from 'node:timers/promises'; +import { replaceInFile, writeMultipleFiles } from '../../utils/fs'; +import { ng, silentNg, waitForAnyProcessOutputToMatch } from '../../utils/process'; +import { installWorkspacePackages, uninstallPackage } from '../../utils/packages'; +import { ngServe, useSha } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + await writeMultipleFiles({ + // Replace the template of app.ng.html as it makes it harder to debug + 'src/app/app.html': '', + 'src/app/app.routes.ts': ` + import { Routes } from '@angular/router'; + import { Home } from './home/home'; + + export const routes: Routes = [ + { path: 'home', component: Home } + ]; + `, + 'src/app/app.routes.server.ts': ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const serverRoutes: ServerRoute[] = [ + { path: '**', renderMode: RenderMode.Server } + ]; + `, + 'src/server.ts': ` + import { AngularNodeAppEngine, writeResponseToNodeResponse, isMainModule, createNodeRequestHandler } from '@angular/ssr/node'; + import express from 'express'; + import { join } from 'node:path'; + + export function app(): express.Express { + const server = express(); + const browserDistFolder = join(import.meta.dirname, '../browser'); + const angularNodeAppEngine = new AngularNodeAppEngine(); + + server.use('/api/{*splat}', (req, res) => { + res.json({ hello: 'foo' }) + }); + + server.use(express.static(browserDistFolder, { + maxAge: '1y', + index: 'index.html' + })); + + server.use(async(req, res, next) => { + const response = await angularNodeAppEngine.handle(req); + if (response) { + writeResponseToNodeResponse(response, res); + } else { + next(); + } + }); + + return server; + } + + const server = app(); + if (isMainModule(import.meta.url)) { + const port = process.env['PORT'] || 4000; + server.listen(port, (error) => { + if (error) { + throw error; + } + + console.log(\`Node Express server listening on http://localhost:\${port}\`); + }); + } + + export const reqHandler = createNodeRequestHandler(server); + `, + }); + + await silentNg('generate', 'component', 'home'); + + const port = await ngServe(); + + // Verify the server is running and the API response is correct. + await validateResponse('/main.js', /bootstrapApplication/); + await validateResponse('/api/test', /foo/); + await validateResponse('/home', /home works/); + + // Modify the home component and validate the change. + await modifyFileAndWaitUntilUpdated( + 'src/app/home/home.html', + 'home works', + 'yay home works!!!', + true, + ); + await validateResponse('/api/test', /foo/); + await validateResponse('/home', /yay home works/); + + // Modify the API response and validate the change. + await modifyFileAndWaitUntilUpdated('src/server.ts', `{ hello: 'foo' }`, `{ hello: 'bar' }`); + await validateResponse('/api/test', /bar/); + await validateResponse('/home', /yay home works/); + + async function validateResponse(pathname: string, match: RegExp): Promise { + const response = await fetch(new URL(pathname, `http://localhost:${port}`)); + const text = await response.text(); + assert.match(text, match); + assert.equal(response.status, 200); + } +} + +async function modifyFileAndWaitUntilUpdated( + filePath: string, + searchValue: string, + replaceValue: string, + hmr = false, +): Promise { + await Promise.all([ + waitForAnyProcessOutputToMatch( + hmr ? /Component update sent to client/ : /Page reload sent to client/, + ), + setTimeout(100).then(() => replaceInFile(filePath, searchValue, replaceValue)), + ]); +} diff --git a/tests/e2e/tests/vite/ssr-entry-fastify.ts b/tests/e2e/tests/vite/ssr-entry-fastify.ts new file mode 100644 index 000000000000..c50e8c2200f6 --- /dev/null +++ b/tests/e2e/tests/vite/ssr-entry-fastify.ts @@ -0,0 +1,123 @@ +import assert from 'node:assert'; +import { setTimeout } from 'node:timers/promises'; +import { replaceInFile, writeMultipleFiles } from '../../utils/fs'; +import { ng, silentNg, waitForAnyProcessOutputToMatch } from '../../utils/process'; +import { installPackage, installWorkspacePackages, uninstallPackage } from '../../utils/packages'; +import { ngServe, useSha } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + await installPackage('fastify@5'); + + await writeMultipleFiles({ + // Replace the template of app.ng.html as it makes it harder to debug + 'src/app/app.html': '', + 'src/app/app.routes.ts': ` + import { Routes } from '@angular/router'; + import { Home } from './home/home'; + + export const routes: Routes = [ + { path: 'home', component: Home } + ]; + `, + 'src/app/app.routes.server.ts': ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const serverRoutes: ServerRoute[] = [ + { path: '**', renderMode: RenderMode.Server } + ]; + `, + 'src/server.ts': ` + import { AngularNodeAppEngine, writeResponseToNodeResponse, isMainModule, createNodeRequestHandler } from '@angular/ssr/node'; + import fastify from 'fastify'; + + export function app() { + const server = fastify(); + const angularNodeAppEngine = new AngularNodeAppEngine(); + server.get('/api/*', (req, reply) => reply.send({ hello: 'foo' })); + server.get('*', async (req, reply) => { + try { + const response = await angularNodeAppEngine.handle(req.raw); + if (response) { + await writeResponseToNodeResponse(response, reply.raw); + } else { + reply.callNotFound(); + } + } catch (error) { + reply.send(error); + } + }); + + return server; + } + + const server = app(); + if (isMainModule(import.meta.url)) { + const port = +(process.env['PORT'] || 4000); + server.listen({ port }, () => { + console.log(\`Fastify server listening on http://localhost:\${port}\`); + }); + } + + export const reqHandler = createNodeRequestHandler(async (req, res) => { + await server.ready(); + server.server.emit('request', req, res); + }); + `, + }); + + await silentNg('generate', 'component', 'home'); + + const port = await ngServe(); + + // Verify the server is running and the API response is correct. + await validateResponse('/main.js', /bootstrapApplication/); + await validateResponse('/api/test', /foo/); + await validateResponse('/home', /home works/); + + // Modify the home component and validate the change. + await modifyFileAndWaitUntilUpdated( + 'src/app/home/home.html', + 'home works', + 'yay home works!!!', + true, + ); + await validateResponse('/api/test', /foo/); + await validateResponse('/home', /yay home works/); + + // Modify the API response and validate the change. + await modifyFileAndWaitUntilUpdated('src/server.ts', `{ hello: 'foo' }`, `{ hello: 'bar' }`); + await validateResponse('/api/test', /bar/); + await validateResponse('/home', /yay home works/); + + async function validateResponse(pathname: string, match: RegExp): Promise { + const response = await fetch(new URL(pathname, `http://localhost:${port}`)); + const text = await response.text(); + assert.match(text, match); + assert.equal(response.status, 200); + } +} + +async function modifyFileAndWaitUntilUpdated( + filePath: string, + searchValue: string, + replaceValue: string, + hmr = false, +): Promise { + await Promise.all([ + waitForAnyProcessOutputToMatch( + hmr ? /Component update sent to client/ : /Page reload sent to client/, + ), + setTimeout(100).then(() => replaceInFile(filePath, searchValue, replaceValue)), + ]); +} diff --git a/tests/e2e/tests/vite/ssr-entry-h3.ts b/tests/e2e/tests/vite/ssr-entry-h3.ts new file mode 100644 index 000000000000..4a4f91066000 --- /dev/null +++ b/tests/e2e/tests/vite/ssr-entry-h3.ts @@ -0,0 +1,114 @@ +import assert from 'node:assert'; +import { setTimeout } from 'node:timers/promises'; +import { replaceInFile, writeMultipleFiles } from '../../utils/fs'; +import { ng, silentNg, waitForAnyProcessOutputToMatch } from '../../utils/process'; +import { installPackage, installWorkspacePackages, uninstallPackage } from '../../utils/packages'; +import { ngServe, useSha } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + await installPackage('h3@1'); + + await writeMultipleFiles({ + // Replace the template of app.ng.html as it makes it harder to debug + 'src/app/app.html': '', + 'src/app/app.routes.ts': ` + import { Routes } from '@angular/router'; + import { Home } from './home/home'; + + export const routes: Routes = [ + { path: 'home', component: Home } + ]; + `, + 'src/app/app.routes.server.ts': ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const serverRoutes: ServerRoute[] = [ + { path: '**', renderMode: RenderMode.Server } + ]; + `, + 'src/server.ts': ` + import { AngularAppEngine, createRequestHandler } from '@angular/ssr'; + import { createApp, createRouter, toWebHandler, defineEventHandler, toWebRequest } from 'h3'; + + export function app() { + const server = createApp(); + const router = createRouter(); + const angularAppEngine = new AngularAppEngine(); + + router.use( + '/api/**', + defineEventHandler(() => ({ hello: 'foo' })), + ); + + router.use( + '/**', + defineEventHandler((event) => angularAppEngine.handle(toWebRequest(event))), + ); + + server.use(router); + + return server; + } + + const server = app(); + + export const reqHandler = createRequestHandler(toWebHandler(server)); + `, + }); + + await silentNg('generate', 'component', 'home'); + + const port = await ngServe(); + + // Verify the server is running and the API response is correct. + await validateResponse('/main.js', /bootstrapApplication/); + await validateResponse('/api/test', /foo/); + await validateResponse('/home', /home works/); + + // Modify the home component and validate the change. + await modifyFileAndWaitUntilUpdated( + 'src/app/home/home.html', + 'home works', + 'yay home works!!!', + true, + ); + await validateResponse('/api/test', /foo/); + await validateResponse('/home', /yay home works/); + + // Modify the API response and validate the change. + await modifyFileAndWaitUntilUpdated('src/server.ts', `{ hello: 'foo' }`, `{ hello: 'bar' }`); + await validateResponse('/api/test', /bar/); + await validateResponse('/home', /yay home works/); + + async function validateResponse(pathname: string, match: RegExp): Promise { + const response = await fetch(new URL(pathname, `http://localhost:${port}`)); + const text = await response.text(); + assert.match(text, match); + assert.equal(response.status, 200); + } +} + +async function modifyFileAndWaitUntilUpdated( + filePath: string, + searchValue: string, + replaceValue: string, + hmr = false, +): Promise { + await Promise.all([ + waitForAnyProcessOutputToMatch( + hmr ? /Component update sent to client/ : /Page reload sent to client/, + ), + setTimeout(100).then(() => replaceInFile(filePath, searchValue, replaceValue)), + ]); +} diff --git a/tests/e2e/tests/vite/ssr-entry-hono.ts b/tests/e2e/tests/vite/ssr-entry-hono.ts new file mode 100644 index 000000000000..0b0b8220fe6b --- /dev/null +++ b/tests/e2e/tests/vite/ssr-entry-hono.ts @@ -0,0 +1,106 @@ +import assert from 'node:assert'; +import { setTimeout } from 'node:timers/promises'; +import { replaceInFile, writeMultipleFiles } from '../../utils/fs'; +import { ng, silentNg, waitForAnyProcessOutputToMatch } from '../../utils/process'; +import { installPackage, installWorkspacePackages, uninstallPackage } from '../../utils/packages'; +import { ngServe, useSha } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + await installPackage('hono@4'); + + await writeMultipleFiles({ + // Replace the template of app.ng.html as it makes it harder to debug + 'src/app/app.html': '', + 'src/app/app.routes.ts': ` + import { Routes } from '@angular/router'; + import { Home } from './home/home'; + + export const routes: Routes = [ + { path: 'home', component: Home } + ]; + `, + 'src/app/app.routes.server.ts': ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const serverRoutes: ServerRoute[] = [ + { path: '**', renderMode: RenderMode.Server } + ]; + `, + 'src/server.ts': ` + import { AngularAppEngine, createRequestHandler } from '@angular/ssr'; + import { Hono } from 'hono'; + + export function app() { + const server = new Hono(); + const angularAppEngine = new AngularAppEngine(); + + server.get('/api/*', (c) => c.json({ hello: 'foo' })); + server.get('/*', async (c) => { + const res = await angularAppEngine.handle(c.req.raw); + return res || undefined + }); + + return server; + } + + const server = app(); + export const reqHandler = createRequestHandler(server.fetch); + `, + }); + + await silentNg('generate', 'component', 'home'); + + const port = await ngServe(); + + // Verify the server is running and the API response is correct. + await validateResponse('/main.js', /bootstrapApplication/); + await validateResponse('/api/test', /foo/); + await validateResponse('/home', /home works/); + + // Modify the home component and validate the change. + await modifyFileAndWaitUntilUpdated( + 'src/app/home/home.html', + 'home works', + 'yay home works!!!', + true, + ); + await validateResponse('/api/test', /foo/); + await validateResponse('/home', /yay home works/); + + // Modify the API response and validate the change. + await modifyFileAndWaitUntilUpdated('src/server.ts', `{ hello: 'foo' }`, `{ hello: 'bar' }`); + await validateResponse('/api/test', /bar/); + await validateResponse('/home', /yay home works/); + + async function validateResponse(pathname: string, match: RegExp): Promise { + const response = await fetch(new URL(pathname, `http://localhost:${port}`)); + const text = await response.text(); + assert.match(text, match); + assert.equal(response.status, 200); + } +} + +async function modifyFileAndWaitUntilUpdated( + filePath: string, + searchValue: string, + replaceValue: string, + hmr = false, +): Promise { + await Promise.all([ + waitForAnyProcessOutputToMatch( + hmr ? /Component update sent to client/ : /Page reload sent to client/, + ), + setTimeout(100).then(() => replaceInFile(filePath, searchValue, replaceValue)), + ]); +} diff --git a/tests/e2e/tests/vite/ssr-error-stack.ts b/tests/e2e/tests/vite/ssr-error-stack.ts new file mode 100644 index 000000000000..8fce78b9e7ad --- /dev/null +++ b/tests/e2e/tests/vite/ssr-error-stack.ts @@ -0,0 +1,34 @@ +import { doesNotMatch, match } from 'node:assert'; +import { ng } from '../../utils/process'; +import { appendToFile } from '../../utils/fs'; +import { ngServe, useSha } from '../../utils/project'; +import { installWorkspacePackages, uninstallPackage } from '../../utils/packages'; + +export default async function () { + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + // Create Error. + await appendToFile( + 'src/app/app.ts', + ` + (() => { + throw new Error('something happened!'); + })(); + `, + ); + + const port = await ngServe(); + const response = await fetch(`http://localhost:${port}/`); + const text = await response.text(); + + // The error is also sent in the browser, so we don't need to scrap the stderr. + match( + text, + /something happened.+at eval \(.+[\\/]+e2e-test[\\/]+test-project[\\/]+src[\\/]+app[\\/]+app\.ts:\d+:\d+\)/, + ); + doesNotMatch(text, /vite-root/); +} diff --git a/tests/e2e/tests/vite/ssr-new-dep-optimization.ts b/tests/e2e/tests/vite/ssr-new-dep-optimization.ts new file mode 100644 index 000000000000..291e8df20170 --- /dev/null +++ b/tests/e2e/tests/vite/ssr-new-dep-optimization.ts @@ -0,0 +1,60 @@ +import assert from 'node:assert'; +import { + execAndWaitForOutputToMatch, + ng, + waitForAnyProcessOutputToMatch, +} from '../../utils/process'; +import { installWorkspacePackages, uninstallPackage } from '../../utils/packages'; +import { useSha } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; +import { readFile, writeFile } from '../../utils/fs'; +import { findFreePort } from '../../utils/network'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Enable caching to test real development workflow. + await ng('cache', 'clean'); + await ng('cache', 'on'); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + const port = await findFreePort(); + await execAndWaitForOutputToMatch( + 'ng', + ['serve', '--port', port.toString()], + /Application bundle generation complete/, + { ...process.env, CI: '0', NO_COLOR: 'true' }, + ); + await validateResponse('/', /Hello,/); + + await Promise.all([ + waitForAnyProcessOutputToMatch( + /optimized: @angular\/platform-browser\/animations\/async/, + 6000, + ), + writeFile( + 'src/app/app.config.ts', + ` + import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; + ${(await readFile('src/app/app.config.ts')).replace('provideRouter(routes),', 'provideAnimationsAsync(), provideRouter(routes),')} + `, + ), + ]); + + // Verify the app still works. + await validateResponse('/', /Hello,/); + + async function validateResponse(pathname: string, match: RegExp): Promise { + const response = await fetch(new URL(pathname, `http://localhost:${port}`)); + const text = await response.text(); + assert.match(text, match); + } +} diff --git a/tests/e2e/tests/vite/ssr-with-ssl.ts b/tests/e2e/tests/vite/ssr-with-ssl.ts new file mode 100644 index 000000000000..e07d01b06fcc --- /dev/null +++ b/tests/e2e/tests/vite/ssr-with-ssl.ts @@ -0,0 +1,71 @@ +import { Agent, fetch } from 'undici'; +import assert from 'node:assert'; +import { writeMultipleFiles } from '../../utils/fs'; +import { ng, silentNg } from '../../utils/process'; +import { installWorkspacePackages, uninstallPackage } from '../../utils/packages'; +import { ngServe, useSha } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + await writeMultipleFiles({ + // Replace the template of app.ng.html as it makes it harder to debug + 'src/app/app.html': '', + 'src/app/app.routes.ts': ` + import { Routes } from '@angular/router'; + import { Home } from './home/home'; + + export const routes: Routes = [ + { path: 'home', component: Home } + ]; + `, + 'src/app/app.routes.server.ts': ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const serverRoutes: ServerRoute[] = [ + { path: '**', renderMode: RenderMode.Server } + ]; + `, + }); + + await silentNg('generate', 'component', 'home'); + + const port = await ngServe('--ssl'); + + // http 2 + await validateResponse('/main.js', /bootstrapApplication/, true); + await validateResponse('/home', /home works/, true); + + // http 1.1 + await validateResponse('/main.js', /bootstrapApplication/, false); + await validateResponse('/home', /home works/, false); + + async function validateResponse( + pathname: string, + match: RegExp, + allowH2: boolean, + ): Promise { + const response = await fetch(new URL(pathname, `https://localhost:${port}`), { + dispatcher: new Agent({ + connect: { + allowH2, + rejectUnauthorized: false, + }, + }), + }); + + const text = await response.text(); + assert.match(text, match); + assert.equal(response.status, 200); + } +} diff --git a/tests/e2e/tests/vitest/browser-coverage-istanbul.ts b/tests/e2e/tests/vitest/browser-coverage-istanbul.ts new file mode 100644 index 000000000000..66f73162d586 --- /dev/null +++ b/tests/e2e/tests/vitest/browser-coverage-istanbul.ts @@ -0,0 +1,35 @@ +import { ng } from '../../utils/process'; +import { applyVitestBuilder } from '../../utils/vitest'; +import assert from 'node:assert'; +import { expectFileToExist, readFile } from '../../utils/fs'; +import { updateJsonFile } from '../../utils/project'; + +export default async function (): Promise { + // Install ONLY Istanbul coverage package. + // This will trigger the auto-detection logic to use Istanbul even for Node tests. + await applyVitestBuilder({ + coverageIstanbul: true, + }); + + // Use the 'json' reporter to get a machine-readable output for assertions. + await updateJsonFile('angular.json', (json) => { + const project = Object.values(json['projects'])[0] as any; + const test = project['architect']['test']; + test.options = { + coverageReporters: ['json', 'text'], + }; + }); + + // Run tests with coverage (defaults to Node/jsdom environment) + const { stdout } = await ng('test', '--no-watch', '--coverage'); + + // Verify that tests passed + assert.match(stdout, /1 passed/, 'Expected tests to run successfully.'); + + // Verify that coverage files are generated + const coverageJsonPath = 'coverage/test-project/coverage-final.json'; + await expectFileToExist(coverageJsonPath); + + const coverageSummary = JSON.parse(await readFile(coverageJsonPath)); + assert.ok(Object.keys(coverageSummary).length > 0, 'Expected coverage report to not be empty.'); +} diff --git a/tests/e2e/tests/vitest/browser-coverage-sourcemaps.ts b/tests/e2e/tests/vitest/browser-coverage-sourcemaps.ts new file mode 100644 index 000000000000..476c15c41d38 --- /dev/null +++ b/tests/e2e/tests/vitest/browser-coverage-sourcemaps.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { applyVitestBuilder } from '../../utils/vitest'; +import { ng } from '../../utils/process'; +import { expectFileToExist, readFile } from '../../utils/fs'; + +export default async function (): Promise { + await applyVitestBuilder({ + playwright: true, + coverageV8: true, + }); + + // Run tests with coverage in browser mode. + // We use the default passing tests generated for the project. + const { stdout } = await ng( + 'test', + '--no-watch', + '--browsers', + 'chromiumHeadless', + '--coverage', + '--coverage-reporters=json-summary', + ); + + // Verify that tests passed + assert.match(stdout, /pass/, 'Expected tests to run successfully.'); + + // Verify that coverage files are generated + const summaryPath = 'coverage/test-project/coverage-summary.json'; + await expectFileToExist(summaryPath); + + const summary = JSON.parse(await readFile(summaryPath)); + + // Find the key for app.ts (it might be an absolute path) + const appFileKey = Object.keys(summary).find((key) => key.endsWith('app.ts')); + assert.ok(appFileKey, 'Expected coverage summary to contain app.ts.'); + + const appCoverage = summary[appFileKey]; + assert.ok(appCoverage.lines.pct > 0, 'Expected lines percentage to be greater than 0.'); + + // Also verify that spec files are NOT present in the summary + const specFileKey = Object.keys(summary).find((key) => key.endsWith('.spec.ts')); + assert.ok(!specFileKey, 'Expected coverage report to not contain .spec.ts files.'); +} diff --git a/tests/e2e/tests/vitest/browser-coverage-validation.ts b/tests/e2e/tests/vitest/browser-coverage-validation.ts new file mode 100644 index 000000000000..66342746a91a --- /dev/null +++ b/tests/e2e/tests/vitest/browser-coverage-validation.ts @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { applyVitestBuilder } from '../../utils/vitest'; +import { execAndCaptureError } from '../../utils/process'; +import { writeFile } from '../../utils/fs'; +import { stripVTControlCharacters } from 'node:util'; +import { unlink } from 'node:fs/promises'; + +export default async function (): Promise { + await applyVitestBuilder({ + playwright: true, + }); + + // === Case 1: Browser configured via CLI option === + const error1 = await execAndCaptureError('ng', [ + 'test', + '--no-watch', + '--coverage', + '--browsers', + 'firefox', + ]); + const output1 = stripVTControlCharacters(error1.message); + assert.match( + output1, + /Code coverage requires either "@vitest\/coverage-v8" or "@vitest\/coverage-istanbul" to be installed./, + 'Expected validation error for missing coverage packages.', + ); + + await applyVitestBuilder({ + playwright: true, + coverageV8: true, + }); + + const configPath = 'vitest.config.mts'; + const absoluteConfigPath = path.resolve(configPath); + + try { + // === Case 2: Browser configured via vitest.config.mts (name) === + await writeFile( + configPath, + ` + import { defineConfig } from 'vitest/config'; + import { playwright } from '@vitest/browser-playwright'; + + export default defineConfig({ + test: { + coverage: { provider: 'v8' }, + browser: { + enabled: true, + name: 'firefox', + provider: playwright(), + }, + }, + }); + `, + ); + + const error2 = await execAndCaptureError('ng', [ + 'test', + '--no-watch', + '--coverage', + `--runner-config=${absoluteConfigPath}`, + ]); + const output2 = stripVTControlCharacters(error2.message); + assert.match( + output2, + /Code coverage is enabled, but the following configured browsers do not support the V8 coverage provider: firefox/, + 'Expected validation error for unsupported browser with coverage (config name).', + ); + assert.match( + output2, + /Please use the "istanbul" coverage provider/, + 'Expected the error to suggest the istanbul coverage provider.', + ); + + // === Case 3: Browser configured via vitest.config.mts (instances) === + await writeFile( + configPath, + ` + import { defineConfig } from 'vitest/config'; + import { playwright } from '@vitest/browser-playwright'; + + export default defineConfig({ + test: { + coverage: { provider: 'v8' }, + browser: { + enabled: true, + provider: playwright(), + instances: [{ browser: 'firefox' }], + } as any, + }, + }); + `, + ); + + const error3 = await execAndCaptureError('ng', [ + 'test', + '--no-watch', + '--coverage', + `--runner-config=${absoluteConfigPath}`, + ]); + const output3 = stripVTControlCharacters(error3.message); + assert.match( + output3, + /Code coverage is enabled, but the following configured browsers do not support the V8 coverage provider: firefox/, + 'Expected validation error for unsupported browser with coverage (config instances).', + ); + } finally { + // Clean up the config file so it doesn't affect other tests + try { + await unlink(configPath); + } catch {} + } +} diff --git a/tests/e2e/tests/vitest/browser-custom-config-vitest-v4.ts b/tests/e2e/tests/vitest/browser-custom-config-vitest-v4.ts new file mode 100644 index 000000000000..7865830f3b82 --- /dev/null +++ b/tests/e2e/tests/vitest/browser-custom-config-vitest-v4.ts @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { applyVitestBuilder } from '../../utils/vitest'; +import { ng } from '../../utils/process'; +import { writeFile } from '../../utils/fs'; +import { installWorkspacePackages } from '../../utils/packages'; +import { updateJsonFile } from '../../utils/project'; + +export default async function (): Promise { + await applyVitestBuilder({ + skipInstall: true, + }); + + await updateJsonFile('package.json', (json) => { + json.devDependencies = { + ...json.devDependencies, + '@vitest/browser-playwright': '^4', + 'vitest': '^4', + 'playwright': '^1', + }; + }); + + await installWorkspacePackages(); + + await ng('generate', 'component', 'my-comp'); + + // Create vitest-base.config.mts + await writeFile( + 'vitest-base.config.mts', + ` + import { defineConfig } from 'vitest/config'; + import { playwright } from '@vitest/browser-playwright'; + + export default defineConfig({ + test: { + browser: { + enabled: true, + provider: playwright({launchOptions: {executablePath: process.env['CHROME_BIN']}}), + instances: [ + { browser: 'chromium' }, + ], + headless: true, + }, + }, + }); + `, + ); + + // Create a spec file that asserts browser environment + await writeFile( + 'src/browser.spec.ts', + ` + describe('Browser Environment Check', () => { + it('should be running in Chromium', () => { + const ua = navigator.userAgent; + // Fail the test if it's not Chrome/Chromium + if (!ua.includes('Chrome') && !ua.includes('Chromium')) { + throw new Error('Expected to be running in Chrome/Chromium, but User Agent is: ' + ua); + } + }); + }); + `, + ); + + const { stdout } = await ng('test', '--no-watch', '--runner-config'); + + assert.match(stdout, /3 passed/, 'Expected 3 tests to pass.'); +} diff --git a/tests/e2e/tests/vitest/browser-custom-config.ts b/tests/e2e/tests/vitest/browser-custom-config.ts new file mode 100644 index 000000000000..ab9092d038af --- /dev/null +++ b/tests/e2e/tests/vitest/browser-custom-config.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import { applyVitestBuilder } from '../../utils/vitest'; +import { ng } from '../../utils/process'; +import { writeFile } from '../../utils/fs'; + +export default async function (): Promise { + await applyVitestBuilder({ + playwright: true, + }); + + await ng('generate', 'component', 'my-comp'); + + // Create vitest-base.config.mts + await writeFile( + 'vitest-base.config.mts', + ` + import { defineConfig } from 'vitest/config'; + import { playwright } from '@vitest/browser-playwright'; + + export default defineConfig({ + test: { + browser: { + enabled: true, + provider: playwright({launchOptions: {executablePath: process.env['CHROME_BIN']}}), + instances: [ + { browser: 'chromium' }, + ], + headless: true, + }, + }, + }); + `, + ); + + // Create a spec file that asserts browser environment + await writeFile( + 'src/browser.spec.ts', + ` + describe('Browser Environment Check', () => { + it('should be running in Chromium', () => { + const ua = navigator.userAgent; + // Fail the test if it's not Chrome/Chromium + if (!ua.includes('Chrome') && !ua.includes('Chromium')) { + throw new Error('Expected to be running in Chrome/Chromium, but User Agent is: ' + ua); + } + }); + }); + `, + ); + + const { stdout } = await ng('test', '--no-watch', '--runner-config'); + + assert.match(stdout, /3 passed/, 'Expected 3 tests to pass.'); +} diff --git a/tests/e2e/tests/vitest/browser-no-globals.ts b/tests/e2e/tests/vitest/browser-no-globals.ts new file mode 100644 index 000000000000..3a4fcde4b96c --- /dev/null +++ b/tests/e2e/tests/vitest/browser-no-globals.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import { writeFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { applyVitestBuilder } from '../../utils/vitest'; + +/** + * Allow `vitest` import in browser mode. + * @see https://github.com/angular/angular-cli/issues/31745 + */ +export default async function (): Promise { + await applyVitestBuilder({ + playwright: true, + }); + + await writeFile( + 'src/app/app.spec.ts', + ` + import { test, expect } from 'vitest'; + + test('should pass', () => { + expect(true).toBe(true); + }); + `, + ); + + const { stdout } = await ng('test', '--browsers', 'ChromiumHeadless'); + + assert.match(stdout, /1 passed/, 'Expected 1 tests to pass.'); +} diff --git a/tests/e2e/tests/vitest/browser-playwright.ts b/tests/e2e/tests/vitest/browser-playwright.ts new file mode 100644 index 000000000000..54d744e10208 --- /dev/null +++ b/tests/e2e/tests/vitest/browser-playwright.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import { applyVitestBuilder } from '../../utils/vitest'; +import { ng } from '../../utils/process'; +import { writeFile } from '../../utils/fs'; +import { updateJsonFile } from '../../utils/project'; + +export default async function (): Promise { + await applyVitestBuilder({ + playwright: true, + }); + await ng('generate', 'component', 'my-comp'); + + await writeFile( + 'src/setup1.ts', + ` + import { getTestBed } from '@angular/core/testing'; + + getTestBed().configureTestingModule({}); + `, + ); + + await updateJsonFile('tsconfig.spec.json', (json) => { + json.include = [...(json.include || []), 'src/setup1.ts']; + }); + + const { stdout } = await ng( + 'test', + '--no-watch', + '--browsers', + 'chromiumHeadless', + '--setup-files', + 'src/setup1.ts', + ); + + assert.match(stdout, /2 passed/, 'Expected 2 tests to pass.'); +} diff --git a/tests/e2e/tests/vitest/browser-sourcemaps.ts b/tests/e2e/tests/vitest/browser-sourcemaps.ts new file mode 100644 index 000000000000..4bc025a7d2b9 --- /dev/null +++ b/tests/e2e/tests/vitest/browser-sourcemaps.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import { applyVitestBuilder } from '../../utils/vitest'; +import { ng, noSilentNg } from '../../utils/process'; +import { writeFile } from '../../utils/fs'; +import { stripVTControlCharacters } from 'node:util'; + +export default async function (): Promise { + await applyVitestBuilder({ + playwright: true, + }); + + await ng('generate', 'component', 'my-comp'); + + // Add a failing test to verify source map support + await writeFile( + 'src/app/failing.spec.ts', + ` + describe('Failing Test', () => { + it('should fail', () => { + expect(true).toBe(false); + }); + }); + `, + ); + + try { + await noSilentNg('test', '--no-watch', '--browsers', 'chromiumHeadless'); + throw new Error('Expected "ng test" to fail.'); + } catch (error: any) { + const stdout = stripVTControlCharacters(error.stdout || error.message); + // We expect the failure from failing.spec.ts + assert.match(stdout, /1 failed/, 'Expected 1 test to fail.'); + // Check that the stack trace points to the correct file + assert.match( + stdout, + /\bsrc[\/\\]app[\/\\]failing\.spec\.ts:4:\d+/, + 'Expected stack trace to point to the source file.', + ); + } +} diff --git a/tests/e2e/tests/vitest/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 index ce5ace7741f9..153fdaccc6de 100644 --- a/tests/e2e/utils/assets.ts +++ b/tests/e2e/utils/assets.ts @@ -1,50 +1,69 @@ -import {join} from 'path'; -import * as glob from 'glob'; -import {getGlobalVariable} from './env'; -import {relative} from 'path'; -import {copyFile} from './fs'; -import {updateJsonFile} from './project'; -import {silentNpm} from './process'; - +import { join } from 'node:path'; +import { chmod } from 'node:fs/promises'; +import glob from 'fast-glob'; +import { getGlobalVariable } from './env'; +import { resolve } from 'node:path'; +import { copyFile } from './fs'; +import { installWorkspacePackages, setRegistry } from './packages'; +import { getTestProjectDir, useBuiltPackagesVersions } from './project'; export function assetDir(assetName: string) { - return join(__dirname, '../assets', assetName); + return join(__dirname, '../e2e/assets', assetName); } +export function copyProjectAsset(assetName: string, to?: string) { + const tempRoot = join(getGlobalVariable('projects-root'), 'test-project'); + const sourcePath = assetDir(assetName); + const targetPath = join(tempRoot, to || assetName); + + return copyFile(sourcePath, targetPath); +} -export function copyAssets(assetName: string) { - const tempRoot = join(getGlobalVariable('tmp-root'), 'assets', assetName); +export function copyAssets(assetName: string, to?: string) { + const seed = +Date.now(); + const tempRoot = join(getTestAssetsDir(), assetName + '-' + seed); const root = assetDir(assetName); return Promise.resolve() .then(() => { - const allFiles = glob.sync(join(root, '**/*'), { dot: true, nodir: true }); + const allFiles = glob.sync('**/*', { dot: true, cwd: root }); return allFiles.reduce((promise, filePath) => { - const relPath = relative(root, filePath); - const toPath = join(tempRoot, relPath); + const toPath = + to !== undefined ? resolve(getTestProjectDir(), to, filePath) : join(tempRoot, filePath); - return promise.then(() => copyFile(filePath, toPath)); + return promise + .then(() => copyFile(join(root, filePath), toPath)) + .then(() => chmod(toPath, 0o777)); }, Promise.resolve()); }) .then(() => tempRoot); } +/** + * @returns a method that once called will restore the environment + * to use the local NPM registry. + * */ +export async function createProjectFromAsset( + assetName: string, + useNpmPackages = false, + skipInstall = false, +): Promise<() => Promise> { + const dir = await copyAssets(assetName); + process.chdir(dir); -export function createProjectFromAsset(assetName: string) { - const packages = require('../../../lib/packages'); + await setRegistry(!useNpmPackages /** useTestRegistry */); - return Promise.resolve() - .then(() => copyAssets(assetName)) - .then(dir => process.chdir(dir)) - .then(() => updateJsonFile('package.json', json => { - for (const packageName of Object.keys(packages)) { - if (json['dependencies'].hasOwnProperty(packageName)) { - json['dependencies'][packageName] = packages[packageName].dist; - } else if (json['devDependencies'].hasOwnProperty(packageName)) { - json['devDependencies'][packageName] = packages[packageName].dist; - } - } - })) - .then(() => silentNpm('install')); + if (!useNpmPackages) { + await useBuiltPackagesVersions(); + } + if (!skipInstall) { + await installWorkspacePackages(); + } + + return () => setRegistry(true /** useTestRegistry */); +} + +export function getTestAssetsDir(): string { + return join(getGlobalVariable('projects-root'), 'assets'); } diff --git a/tests/e2e/utils/ast.ts b/tests/e2e/utils/ast.ts deleted file mode 100644 index b23be570a8fd..000000000000 --- a/tests/e2e/utils/ast.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { - insertImport as _insertImport, - addImportToModule as _addImportToModule, - NodeHost -} from '@angular-cli/ast-tools'; - - -export function insertImport(file: string, symbol: string, module: string) { - return _insertImport(file, symbol, module) - .then(change => change.apply(NodeHost)); -} - -export function addImportToModule(file: string, symbol: string, module: string) { - return _addImportToModule(file, symbol, module) - .then(change => change.apply(NodeHost)); -} diff --git a/tests/e2e/utils/env.ts b/tests/e2e/utils/env.ts index dd80596f0698..d2f0feece0a7 100644 --- a/tests/e2e/utils/env.ts +++ b/tests/e2e/utils/env.ts @@ -1,13 +1,26 @@ -const global: {[name: string]: any} = Object.create(null); - +const ENV_PREFIX = 'LEGACY_CLI__'; export function setGlobalVariable(name: string, value: any) { - global[name] = value; + if (value === undefined) { + delete process.env[ENV_PREFIX + name]; + } else { + process.env[ENV_PREFIX + name] = JSON.stringify(value); + } } -export function getGlobalVariable(name: string): any { - if (!(name in global)) { +export function getGlobalVariable(name: string): T { + const value = process.env[ENV_PREFIX + name]; + if (value === undefined) { throw new Error(`Trying to access variable "${name}" but it's not defined.`); } - return global[name]; + return JSON.parse(value) as T; +} + +export function getGlobalVariablesEnv(): NodeJS.ProcessEnv { + return Object.keys(process.env) + .filter((v) => v.startsWith(ENV_PREFIX)) + .reduce((vars, n) => { + vars[n] = process.env[n]; + return vars; + }, {}); } diff --git a/tests/e2e/utils/fs.ts b/tests/e2e/utils/fs.ts index 9e0f05b02e87..2ebf7af9d28a 100644 --- a/tests/e2e/utils/fs.ts +++ b/tests/e2e/utils/fs.ts @@ -1,148 +1,134 @@ -import * as fs from 'fs'; -import {dirname} from 'path'; -import {stripIndents} from 'common-tags'; - - -export function readFile(fileName: string) { - return new Promise((resolve, reject) => { - fs.readFile(fileName, 'utf-8', (err: any, data: string) => { - if (err) { - reject(err); - } else { - resolve(data); - } - }); - }); +import { promises as fs, constants } from 'node:fs'; +import { dirname, join } from 'node:path'; + +export function readFile(fileName: string): Promise { + return fs.readFile(fileName, 'utf-8'); } -export function writeFile(fileName: string, content: string) { - return new Promise((resolve, reject) => { - fs.writeFile(fileName, content, (err: any) => { - if (err) { - reject(err); - } else { - resolve(); - } - }); - }); +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 deleteFile(path: string) { - return new Promise((resolve, reject) => { - fs.unlink(path, (err) => { - if (err) { - reject(err); - } else { - resolve(); - } - }); +export function rimraf(path: string): Promise { + return fs.rm(path, { + force: true, + recursive: true, + maxRetries: 3, }); } +export function moveFile(from: string, to: string): Promise { + return fs.rename(from, to); +} -export function 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): Promise { + return fs.symlink(from, to, type); } -export function createDir(path: string) { - _recursiveMkDir(path); +export function createDir(path: string): Promise { + return fs.mkdir(path, { recursive: true }); } +export async function copyFile(from: string, to: string): Promise { + await createDir(dirname(to)); -function _recursiveMkDir(path: string) { - if (fs.existsSync(path)) { - return Promise.resolve(); - } else { - return _recursiveMkDir(dirname(path)) - .then(() => fs.mkdirSync(path)); + return fs.copyFile(from, to, constants.COPYFILE_FICLONE); +} + +export 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 copyFile(from: string, to: string) { - return _recursiveMkDir(dirname(to)) - .then(() => new Promise((resolve, reject) => { - const rd = fs.createReadStream(from); - rd.on('error', (err) => reject(err)); +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)), + ); +} - const wr = fs.createWriteStream(to); - wr.on('error', (err) => reject(err)); - wr.on('close', (ex) => resolve()); +export function appendToFile(filePath: string, text: string, options?: any) { + return readFile(filePath).then((content: string) => + writeFile(filePath, content.concat(text), options), + ); +} - rd.pipe(wr); - })); +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)); -export function writeMultipleFiles(fs: any) { - return Object.keys(fs) - .reduce((previous, curr) => { - return previous.then(() => writeFile(curr, fs[curr])); - }, Promise.resolve()); + 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 function replaceInFile(filePath: string, match: string, replacement: string); -export function replaceInFile(filePath: string, match: RegExp, replacement: string) { - return readFile(filePath) - .then((content: string) => writeFile(filePath, content.replace(match, replacement))); +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); -export function appendToFile(filePath: string, text: string) { - return readFile(filePath) - .then((content: string) => writeFile(filePath, content.concat(text))); + 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); -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...`)); - } - }); - }); + return stats.size; } -export function expectFileToMatch(fileName: string, regEx: RegExp | string) { - return readFile(fileName) - .then(content => { - if (typeof regEx == 'string') { - if (content.indexOf(regEx) == -1) { - throw new Error(stripIndents`File "${fileName}" did not contain "${regEx}"... - Content: - ${content} - ------ - `); - } - } else { - if (!content.match(regEx)) { - throw new Error(stripIndents`File "${fileName}" did not contain "${regEx}"... - Content: - ${content} - ------ - `); - } - } - }); -} - -export function expectFileSizeToBeUnder(fileName: string, sizeInBytes: number) { - return readFile(fileName) - .then(content => { - if (content.length > sizeInBytes) { - throw new Error(`File "${fileName}" exceeded file size of "${sizeInBytes}".`); - } - }); +export async function expectFileSizeToBeUnder(fileName: string, sizeInBytes: number) { + const fileSize = await getFileSize(fileName); + + if (fileSize > sizeInBytes) { + throw new Error( + `File "${fileName}" exceeded file size of "${sizeInBytes}". Size is ${fileSize}.`, + ); + } } diff --git a/tests/e2e/utils/git.ts b/tests/e2e/utils/git.ts index 875ba385e549..39bb47ce7d52 100644 --- a/tests/e2e/utils/git.ts +++ b/tests/e2e/utils/git.ts @@ -1,37 +1,21 @@ -import {git, silentGit} from './process'; +import { git, silentGit } from './process'; - -export function gitClean() { - console.log(' Cleaning git...'); - return silentGit('clean', '-df') - .then(() => silentGit('reset', '--hard')) - .then(() => { - // Checkout missing files - return silentGit('status', '--porcelain') - .then(output => output - .split(/[\n\r]+/g) - .filter(line => line.match(/^ D/)) - .map(line => line.replace(/^\s*\S+\s+/, ''))) - .then(files => silentGit('checkout', ...files)); - }) - .then(() => expectGitToBeClean()); +export async function gitClean(): Promise { + await silentGit('clean', '-df'); + await silentGit('reset', '--hard'); } -export function expectGitToBeClean() { - return git('status', '--porcelain') - .then(output => { - if (output != '') { - throw new Error('Git repo is not clean...'); - } - }); +export async function expectGitToBeClean(): Promise { + const { stdout } = await silentGit('status', '--porcelain'); + if (stdout != '') { + throw new Error('Git repo is not clean...\n' + stdout); + } } -export function gitCommit(message: string) { - return git('add', '-A') - .then(() => git('status', '--porcelain')) - .then(output => { - if (output != '') { - return git('commit', '-am', message); - } - }); +export async function gitCommit(message: string): Promise { + await git('add', '-A'); + const { stdout } = await silentGit('status', '--porcelain'); + if (stdout != '') { + await git('commit', '-am', message); + } } diff --git a/tests/e2e/utils/http.ts b/tests/e2e/utils/http.ts deleted file mode 100644 index fba900c7e6e0..000000000000 --- a/tests/e2e/utils/http.ts +++ /dev/null @@ -1,18 +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, agentOptions: { rejectUnauthorized: false }}; - _request(options, (error: any, response: IncomingMessage, body: string) => { - if (error) { - reject(error); - } else if (response.statusCode >= 400) { - reject(new Error(`Requesting "${url}" returned status code ${response.statusCode}.`)); - } else { - resolve(body); - } - }); - }); -} diff --git a/tests/e2e/utils/network.ts b/tests/e2e/utils/network.ts new file mode 100644 index 000000000000..48602f0000d2 --- /dev/null +++ b/tests/e2e/utils/network.ts @@ -0,0 +1,30 @@ +import { createServer } from 'node:net'; + +/** + * Finds an available network port on the loopback interface (127.0.0.1). + * This is useful for tests that need to bind to a free port to avoid conflicts. + * Explicitly binds to IPv4 localhost to avoid firewall prompts, IPv6 binding issues, and ensure consistency. + * + * @returns A promise that resolves with an available port number. + */ +export function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = createServer(); + srv.once('listening', () => { + const address = srv.address(); + if (!address || typeof address === 'string') { + // Should not happen with TCP, but good for type safety + srv.close(() => reject(new Error('Failed to get server address'))); + return; + } + const port = address.port; + srv.close((e) => (e ? reject(e) : resolve(port))); + }); + + // If an error happens (e.g. during bind), the server is not listening, + // so we should not call close(). + srv.once('error', (e) => reject(e)); + // Explicitly listen on IPv4 localhost to avoid firewall prompts, IPv6 binding issues, and ensure consistency. + srv.listen(0, '127.0.0.1'); + }); +} diff --git a/tests/e2e/utils/packages.ts b/tests/e2e/utils/packages.ts new file mode 100644 index 000000000000..087d771f14b8 --- /dev/null +++ b/tests/e2e/utils/packages.ts @@ -0,0 +1,79 @@ +import { getGlobalVariable } from './env'; +import { ProcessOutput, silentBun, silentNpm, silentPnpm, silentYarn } from './process'; + +export interface PkgInfo { + readonly name: string; + readonly version: string; + readonly path: string; +} + +export function getActivePackageManager(): 'npm' | 'yarn' | 'bun' | 'pnpm' { + return getGlobalVariable('package-manager'); +} + +export async function installWorkspacePackages(options?: { force?: boolean }): Promise { + switch (getActivePackageManager()) { + case 'npm': + const npmArgs = ['install']; + if (options?.force) { + npmArgs.push('--force'); + } + await silentNpm(...npmArgs); + break; + case 'yarn': + await silentYarn('install'); + break; + case 'pnpm': + await silentPnpm('install'); + break; + case 'bun': + await silentBun('install'); + break; + } +} + +export function installPackage(specifier: string, registry?: string): Promise { + const registryOption = registry ? [`--registry=${registry}`] : []; + switch (getActivePackageManager()) { + case 'npm': + return silentNpm('install', specifier, ...registryOption); + case 'yarn': + return silentYarn('add', specifier, ...registryOption); + case 'bun': + return silentBun('add', specifier, ...registryOption); + case 'pnpm': + return silentPnpm('add', specifier, ...registryOption); + } +} + +export async function uninstallPackage(name: string): Promise { + try { + switch (getActivePackageManager()) { + case 'npm': + await silentNpm('uninstall', name); + break; + case 'yarn': + await silentYarn('remove', name); + break; + case 'bun': + await silentBun('remove', name); + break; + case 'pnpm': + await silentPnpm('remove', name); + break; + } + } catch (e) { + // Yarn throws an error when trying to remove a package that is not installed. + console.error(e); + } +} + +export async function setRegistry(useTestRegistry: boolean): Promise { + const url = useTestRegistry + ? getGlobalVariable('package-registry') + : 'https://registry.npmjs.org'; + + // Ensure local test registry is used when outside a project + // Yarn supports both `NPM_CONFIG_REGISTRY` and `YARN_REGISTRY`. + process.env['NPM_CONFIG_REGISTRY'] = url; +} diff --git a/tests/e2e/utils/process.ts b/tests/e2e/utils/process.ts index ab643865dc4d..021536af1688 100644 --- a/tests/e2e/utils/process.ts +++ b/tests/e2e/utils/process.ts @@ -1,36 +1,66 @@ -import * as child_process from 'child_process'; -import {blue, white, yellow} from 'chalk'; -const treeKill = require('tree-kill'); - +import { spawn, SpawnOptions } from 'node:child_process'; +import * as child_process from 'node:child_process'; +import { getGlobalVariable, getGlobalVariablesEnv } from './env'; +import { delimiter, join, resolve } from 'node:path'; +import { stripVTControlCharacters, styleText } from 'node:util'; +import { assertIsError } from './utils'; interface ExecOptions { silent?: boolean; waitForMatch?: RegExp; + env?: NodeJS.ProcessEnv; + stdin?: string; + cwd?: string; } +/** + * While `NPM_CONFIG_` and `YARN_` are case insensitive we filter based on case. + * This is because when invoking a command using `yarn` it will add a bunch of these variables in lower case. + * This causes problems when we try to update the variables during the test setup. + */ +const NPM_CONFIG_RE = /^(NPM_CONFIG_|YARN_|NO_UPDATE_NOTIFIER)/; let _processes: child_process.ChildProcess[] = []; -function _exec(options: ExecOptions, cmd: string, args: string[]): Promise { - let stdout = ''; - let stderr = ''; - const cwd = process.cwd(); - console.log(white( - ` ==========================================================================================` - )); +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); + args = args.filter((x) => x !== undefined); const flags = [ options.silent && 'silent', - options.waitForMatch && `matching(${options.waitForMatch})` + options.waitForMatch && `matching(${options.waitForMatch})`, ] - .filter(x => !!x) // Remove false and undefined. + .filter((x) => !!x) // Remove false and undefined. .join(', ') - .replace(/^(.+)$/, ' [$1]'); // Proper formatting. + .replace(/^(.+)$/, ' [$1]'); // Proper formatting. - console.log(blue(` Running \`${cmd} ${args.map(x => `"${x}"`).join(' ')}\`${flags}...`)); - console.log(blue(` CWD: ${cwd}`)); - const spawnOptions: any = {cwd}; + console.log( + styleText(['blue'], `Running \`${cmd} ${args.map((x) => `"${x}"`).join(' ')}\`${flags}...`), + ); + console.log(styleText(['blue'], `CWD: ${cwd}`)); + + const spawnOptions: SpawnOptions = { + cwd, + env: { ...env, PATH: paths }, + }; if (process.platform.startsWith('win')) { args.unshift('/c', cmd); @@ -39,95 +69,425 @@ function _exec(options: ExecOptions, cmd: string, args: string[]): Promise { - stdout += data.toString('utf-8'); - if (options.silent) { - return; - } - data.toString('utf-8') - .split(/[\n\r]+/) - .filter(line => line !== '') - .forEach(line => console.log(' ' + line)); - }); - childProcess.stderr.on('data', (data: Buffer) => { - stderr += data.toString('utf-8'); - data.toString('utf-8') - .split(/[\n\r]+/) - .filter(line => line !== '') - .forEach(line => console.error(yellow(' ' + line))); - }); _processes.push(childProcess); // Create the error here so the stack shows who called this function. - const err = new Error(`Running "${cmd} ${args.join(' ')}" returned error code `); - return new Promise((resolve, reject) => { - childProcess.on('exit', (error: any) => { - _processes = _processes.filter(p => p !== childProcess); - - if (!error) { - resolve(stdout); - } else { - err.message += `${error}...\n\nSTDOUT:\n${stdout}\n`; - reject(err); + const error = new Error(); + + const processPromise = new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + let matched = false; + + // Return log info about the current process status + function envDump() { + return `STDOUT:\n${stdout}\n\nSTDERR:\n${stderr}`; + } + + childProcess.stdout!.on('data', (data: Buffer) => { + stdout += data.toString('utf-8'); + + if (options.waitForMatch && stdout.match(options.waitForMatch)) { + resolve({ stdout, stderr }); + matched = true; } + + if (options.silent) { + return; + } + + data + .toString('utf-8') + .split(/[\n\r]+/) + .filter((line) => line !== '') + .forEach((line) => console.log(' ' + line)); }); - if (options.waitForMatch) { - childProcess.stdout.on('data', (data: Buffer) => { - if (data.toString().match(options.waitForMatch)) { - resolve(stdout); - } - }); + 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; } -export function killAllProcesses(signal = 'SIGTERM') { - _processes.forEach(process => treeKill(process.pid, signal)); - _processes = []; +/** + * 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 execAndWaitForOutputToMatch(cmd: string, args: string[], match: RegExp) { - return _exec({ waitForMatch: match }, 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 function silentExecAndWaitForOutputToMatch(cmd: string, args: string[], match: RegExp) { - return _exec({ silent: true, waitForMatch: match }, cmd, args); +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[]) { - if (args[0] == 'build' || args[0] == 'serve' || args[0] == 'test') { - return silentNg(...args, '--no-progress'); + 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 _exec({}, 'ng', args); + return noSilentNg(...args); } } +export function noSilentNg(...args: string[]) { + return _exec({}, 'ng', args); +} + export function silentNg(...args: string[]) { - return _exec({silent: true}, 'ng', args); + return _exec({ silent: true }, 'ng', args); } -export function silentNpm(...args: string[]) { - return _exec({silent: true}, 'npm', 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 npm(...args: string[]) { - return _exec({}, 'npm', args); +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({}, 'node', args); + return _exec({}, process.execPath, args); } export function git(...args: string[]) { - return _exec({}, 'git', args); + return _exec({}, process.env.GIT_BIN || 'git', args); } export function silentGit(...args: string[]) { - return _exec({silent: true}, 'git', args); + return _exec({ silent: true }, process.env.GIT_BIN || 'git', args); +} + +/** + * Launch the given entry in an child process isolated to the test environment. + * + * The test environment includes the local NPM registry, isolated NPM globals, + * the PATH variable only referencing the local node_modules and local NPM + * registry (not the test runner or standard global node_modules). + */ +export async function launchTestProcess(entry: string, ...args: any[]): Promise { + 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 index 5adb061ba38c..0deb6ea48262 100644 --- a/tests/e2e/utils/project.ts +++ b/tests/e2e/utils/project.ts @@ -1,69 +1,250 @@ -import {readFile, writeFile} from './fs'; -import {silentExecAndWaitForOutputToMatch, silentNpm, ng} from './process'; -import {getGlobalVariable} from './env'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { prerelease, SemVer } from 'semver'; +import { getGlobalVariable } from './env'; +import { readFile, replaceInFile, writeFile } from './fs'; +import { gitCommit } from './git'; +import { findFreePort } from './network'; +import { installWorkspacePackages, PkgInfo } from './packages'; +import { execAndWaitForOutputToMatch, git, ng } from './process'; +import { join } from 'node:path'; -const packages = require('../../../lib/packages'); +export function updateJsonFile(filePath: string, fn: (json: any) => any | void) { + return readFile(filePath).then((tsConfigJson) => { + // Remove single and multiline comments + const tsConfig = JSON.parse(tsConfigJson.replace(/\/\*\s(.|\n|\r)*\s\*\/|\/\/.*/g, '')) as any; + const result = fn(tsConfig) || tsConfig; + + return writeFile(filePath, JSON.stringify(result, null, 2)); + }); +} +export function updateTsConfig(fn: (json: any) => any | void) { + return updateJsonFile('tsconfig.json', fn); +} -const tsConfigPath = 'src/tsconfig.json'; +export async function ngServe(...args: string[]) { + const port = await findFreePort(); + const esbuild = getGlobalVariable('argv')['esbuild']; + const validBundleRegEx = esbuild ? /complete\./ : /Compiled successfully\./; -export function updateJsonFile(filePath: string, fn: (json: any) => any | void) { - return readFile(filePath) - .then(tsConfigJson => { - const tsConfig = JSON.parse(tsConfigJson); - const result = fn(tsConfig) || tsConfig; + await execAndWaitForOutputToMatch( + 'ng', + ['serve', '--port', String(port), ...args], + validBundleRegEx, + ); - return writeFile(filePath, JSON.stringify(result, null, 2)); - }); + return port; } +export async function prepareProjectForE2e(name: string) { + const argv: Record = getGlobalVariable('argv'); -export function updateTsConfig(fn: (json: any) => any | void) { - return updateJsonFile(tsConfigPath, fn); + await git('config', 'user.email', 'angular-core+e2e@google.com'); + await git('config', 'user.name', 'Angular CLI E2E'); + await git('config', 'commit.gpgSign', 'false'); + await git('config', 'core.longpaths', 'true'); + + if (argv['ng-snapshots'] || argv['ng-tag']) { + await useSha(); + } + + 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'); -export function ngServe(...args: string[]) { - return silentExecAndWaitForOutputToMatch('ng', - ['serve', '--no-progress', ...args], /webpack: bundle is now VALID/); + 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; + } -export function createProject(name: string, ...args: string[]) { - return Promise.resolve() - .then(() => process.chdir(getGlobalVariable('tmp-root'))) - .then(() => ng('new', name, '--skip-npm', ...args)) - .then(() => process.chdir(name)) - .then(() => updateJsonFile('package.json', json => { - Object.keys(packages).forEach(pkgName => { - json['dependencies'][pkgName] = packages[pkgName].dist; - }); - })) - .then(() => { - const argv: any = getGlobalVariable('argv'); - if (argv.nightly || argv['ng-sha']) { - const label = argv['ng-sha'] ? `#2.0.0-${argv['ng-sha']}` : ''; - return updateJsonFile('package.json', json => { - // Install over the project with nightly builds. - Object.keys(json['dependencies'] || {}) - .filter(name => name.match(/^@angular\//)) - .forEach(name => { - const pkgName = name.split(/\//)[1]; - json['dependencies'][`@angular/${pkgName}`] - = `github:angular/${pkgName}-builds${label}`; - }); - - Object.keys(json['devDependencies'] || {}) - .filter(name => name.match(/^@angular\//)) - .forEach(name => { - const pkgName = name.split(/\//)[1]; - json['devDependencies'][`@angular/${pkgName}`] - = `github:angular/${pkgName}-builds${label}`; - }); + // 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 '), + ); } - }) - .then(() => console.log(`Project ${name} created... Installing npm.`)) - .then(() => silentNpm('install')); + } + + 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 index 0e89816b07c1..0c255f53eb86 100644 --- a/tests/e2e/utils/utils.ts +++ b/tests/e2e/utils/utils.ts @@ -1,20 +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(() => { +export function expectToFail(fn: () => Promise, errorMessage?: string): Promise { + return fn().then( + () => { const functionSource = fn.name || (fn).source || fn.toString(); const errorDetails = errorMessage ? `\n\tDetails:\n\t${errorMessage}` : ''; throw new Error( - `Function ${functionSource} was expected to fail, but succeeded.${errorDetails}`); - }, () => { }); + `Function ${functionSource} was expected to fail, but succeeded.${errorDetails}`, + ); + }, + (err) => { + return err instanceof Error ? err : new Error(err); + }, + ); } -export function isMobileTest() { - return !!process.env['MOBILE_TEST']; +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 function wait(msecs: number) { - return new Promise((resolve) => { - setTimeout(resolve, msecs); - }); +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.js b/tests/e2e_runner.js deleted file mode 100644 index 5b869dd03517..000000000000 --- a/tests/e2e_runner.js +++ /dev/null @@ -1,191 +0,0 @@ -/*eslint-disable no-console */ -'use strict'; -require('../lib/bootstrap-local'); - -Error.stackTraceLimit = Infinity; - -/** - * This file is ran using the command line, not using Jasmine / Mocha. - */ -const chalk = require('chalk'); -const gitClean = require('./e2e/utils/git').gitClean; -const glob = require('glob'); -const minimist = require('minimist'); -const path = require('path'); -const blue = chalk.blue; -const bold = chalk.bold; -const green = chalk.green; -const red = chalk.red; -const white = chalk.white; - -const setGlobalVariable = require('./e2e/utils/env').setGlobalVariable; - - -/** - * 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. - * --nolink Skip linking your local angular-cli directory. Can save a few seconds. - * --ng-sha=SHA Use a specific ng-sha. Similar to nightly but point to a master SHA instead - * of using the latest. - * --nightly Install angular nightly builds over the test project. - * --reuse=/path Use a path instead of create a new project. That project should have been - * created, and npm installed. Ideally you want a project created by a previous - * run of e2e. - * 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': ['debug', 'nolink', 'nightly', 'noproject'], - 'string': ['reuse', 'ng-sha'] -}); - - -/** - * Set the error code of the process to 255. This is to ensure that if something forces node - * to exit without finishing properly, the error code will be 255. Right now that code is not used. - * - * When tests succeed we already call `process.exit(0)`, so this doesn't change any correct - * behaviour. - * - * One such case that would force node <= v6 to exit with code 0, is a Promise that doesn't resolve. - */ -process.exitCode = 255; - - -let currentFileName = null; -let index = 0; - -const e2eRoot = path.join(__dirname, 'e2e'); -const allSetups = glob.sync(path.join(e2eRoot, 'setup/**/*'), { nodir: true }) - .map(name => path.relative(e2eRoot, name)) - .sort(); -const allTests = glob.sync(path.join(e2eRoot, 'tests/**/*'), { nodir: true }) - .map(name => path.relative(e2eRoot, name)) - .sort(); - -const testsToRun = allSetups - .concat(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$/, ''); - }); - })); - - -/** - * 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) => { - // 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 = (typeof module == 'function') ? module - : (typeof module.default == 'function') ? module.default - : function() { - throw new Error('Invalid test module.'); - }; - - let clean = true; - let previousDir = null; - return Promise.resolve() - .then(() => printHeader(currentFileName)) - .then(() => previousDir = process.cwd()) - .then(() => fn(() => clean = false)) - .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) { - return gitClean(); - } - }) - .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) { - const text = `${++index} of ${testsToRun.length}`; - console.log(green(`Running "${bold(blue(testName))}" (${bold(white(text))})...`)); - - 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/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/helpers/exec-stub.js b/tests/helpers/exec-stub.js deleted file mode 100644 index 42e7f0e8ec46..000000000000 --- a/tests/helpers/exec-stub.js +++ /dev/null @@ -1,71 +0,0 @@ -'use strict'; -var child_process = require('child_process'); -var sinon = require('sinon'); - -class ExecStub { - constructor() { - this.execOrig = child_process.exec; - this.stub = sinon.stub(child_process, 'exec', this.execStubFunc.bind(this)); - this.stack = []; - this.failed = false; - } - execStubFunc(cmd) { - let resp; - - // console.log('####running', cmd); - - if (this.failed) { - resp = this.failedExec('ExecStub - in fail mode'); - return resp.apply(null, arguments); - } - - if (this.stack.length === 0) { - this.failed = true; - resp = this.failedExec('ExecStub - expected stack size exceeded'); - return resp.apply(null, arguments); - } - - let item = this.stack.shift(); - - // console.log('####expected', item.cmd); - - if (cmd !== item.cmd) { - this.failed = true; - resp = this.failedExec(`ExecStub - unexpected command: ${cmd}`); - return resp.apply(null, arguments); - } - - return item.resp.apply(null, arguments); - } - hasFailed() { - return this.failed; - } - hasEmptyStack() { - return this.stack.length === 0; - } - restore() { - this.stub.restore(); - return this; - } - addExecSuccess(cmd, sdout) { - sdout = sdout || ''; - this.stack.push({ - cmd, - resp: (cmd, opt, cb) => (cb ? cb : opt)(null, sdout, null) - }); - return this; - } - addExecError(cmd, stderr) { - stderr = stderr || ''; - this.stack.push({ - cmd, - resp: (cmd, opt, cb) => (cb ? cb : opt)(new Error(stderr), null, stderr) - }); - return this; - } - failedExec(reason) { - return (cmd, opt, cb) => (cb ? cb : opt)(new Error(reason), null, reason) - } -} - -module.exports = ExecStub; diff --git a/tests/helpers/mock-ui.js b/tests/helpers/mock-ui.js deleted file mode 100644 index 07f322998037..000000000000 --- a/tests/helpers/mock-ui.js +++ /dev/null @@ -1,46 +0,0 @@ -'use strict'; - -var UI = require('angular-cli/ember-cli/lib/ui'); -var through = require('through'); -var Promise = require('angular-cli/ember-cli/lib/ext/promise'); - -module.exports = MockUI; -function MockUI() { - this.output = ''; - - UI.call(this, { - inputStream: through(), - outputStream: through(function (data) { - this.output += data; - }.bind(this)), - errorStream: through(function (data) { - this.errors += data; - }.bind(this)) - }); -} - -MockUI.prototype = Object.create(UI.prototype); -MockUI.prototype.constructor = MockUI; -MockUI.prototype.clear = function () { - this.output = ''; -}; - -MockUI.prototype.waitForPrompt = function () { - if (!this._waitingForPrompt) { - var promise, resolver; - promise = new Promise(function (resolve) { - resolver = resolve; - }); - this._waitingForPrompt = promise; - this._promptResolver = resolver; - } - return this._waitingForPrompt; -}; - -MockUI.prototype.prompt = function (opts, cb) { - if (this._waitingForPrompt) { - this._waitingForPrompt = null; - this._promptResolver(); - } - return UI.prototype.prompt.call(this, opts, cb); -}; diff --git a/tests/helpers/ng.js b/tests/helpers/ng.js deleted file mode 100644 index a34be6964bcc..000000000000 --- a/tests/helpers/ng.js +++ /dev/null @@ -1,20 +0,0 @@ -'use strict'; - -var MockUI = require('./mock-ui'); -var Cli = require('angular-cli/lib/cli'); - -module.exports = function ng(args) { - var cli; - - process.env.PWD = process.cwd(); - - cli = new Cli({ - inputStream: [], - outputStream: [], - cliArgs: args, - UI: MockUI, - testing: true - }); - - return cli; -}; diff --git a/tests/helpers/tmp.js b/tests/helpers/tmp.js deleted file mode 100644 index 733e7a3a8ef4..000000000000 --- a/tests/helpers/tmp.js +++ /dev/null @@ -1,25 +0,0 @@ -'use strict'; - -var fs = require('fs-extra'); -var existsSync = require('exists-sync'); -var Promise = require('angular-cli/ember-cli/lib/ext/promise'); -var remove = Promise.denodeify(fs.remove); -var root = process.cwd(); - -module.exports.setup = function (path) { - process.chdir(root); - - return remove(path).then(function () { - fs.mkdirsSync(path); - }); -}; - -module.exports.teardown = function (path) { - process.chdir(root); - - if (existsSync(path)) { - return remove(path); - } else { - return Promise.resolve(); - } -}; diff --git a/tests/models/config.spec.ts b/tests/models/config.spec.ts deleted file mode 100644 index ece9ee4f5969..000000000000 --- a/tests/models/config.spec.ts +++ /dev/null @@ -1,106 +0,0 @@ -import {CliConfig} from 'angular-cli/models/config/config'; -import * as fs from 'fs'; -import * as path from 'path'; -import {CliConfig as ConfigInterface} from './spec-schema'; - -const expect = require('chai').expect; - - -describe('Config', () => { - let schema = JSON.parse(fs.readFileSync(path.join(__dirname, 'spec-schema.json'), 'utf-8')); - - it('works', () => { - const cliConfig = new CliConfig(null, schema, { - requiredKey: 1, - stringKey: 'stringValue' - }); - const config = cliConfig.config; - - expect(config.requiredKey).to.equal(1); - expect(config.stringKey).to.equal('stringValue'); - expect(config.stringKeyDefault).to.equal('defaultValue'); - expect(config.booleanKey).to.equal(undefined); - - expect(config.arrayKey1).to.equal(undefined); - expect(() => config.arrayKey1[0]).to.throw(); - - expect(config.numberKey).to.equal(undefined); - config.numberKey = 33; - expect(config.numberKey).to.equal(33); - }); - - describe('Get', () => { - it('works', () => { - const config = new CliConfig(null, schema, { - requiredKey: 1, - stringKey: 'stringValue' - }); - - expect(config.get('requiredKey')).to.equal(1); - expect(config.get('stringKey')).to.equal('stringValue'); - expect(config.get('booleanKey')).to.equal(undefined); - }); - - it('will never throw', () => { - const config = new CliConfig(null, schema, { - requiredKey: 1 - }); - - expect(config.get('arrayKey1')).to.equal(undefined); - expect(config.get('arrayKey2[0]')).to.equal(undefined); - expect(config.get('arrayKey2[0].stringKey')).to.equal(undefined); - expect(config.get('arrayKey2[0].stringKey.a.b.c.d')).to.equal(undefined); - }); - }); - - it('handles fallback values', () => { - const cliConfig = new CliConfig(null, schema, - { requiredKey: 1 }, - [ - { requiredKey: 1, stringKey: 'stringValue' }, - { requiredKey: 1, numberKey: 1 } - ] - ); - - // Check on string. - expect(cliConfig.isDefined('stringKey')).to.equal(false); - expect(cliConfig.config.stringKey).to.equal('stringValue'); - - cliConfig.config.stringKey = 'stringValue2'; - expect(cliConfig.isDefined('stringKey')).to.equal(true); - expect(cliConfig.config.stringKey).to.equal('stringValue2'); - - cliConfig.deletePath('stringKey'); - expect(cliConfig.isDefined('stringKey')).to.equal(false); - expect(cliConfig.config.stringKey).to.equal('stringValue'); - - // Check on number (which is 2 fallbacks behind) - expect(cliConfig.isDefined('numberKey')).to.equal(false); - expect(cliConfig.config.numberKey).to.equal(1); - - cliConfig.config.numberKey = 2; - expect(cliConfig.isDefined('numberKey')).to.equal(true); - expect(cliConfig.config.numberKey).to.equal(2); - - cliConfig.deletePath('numberKey'); - expect(cliConfig.isDefined('numberKey')).to.equal(false); - expect(cliConfig.config.numberKey).to.equal(1); - }); - - it('saves', () => { - const jsonObject = { - requiredKey: 1, - arrayKey2: [{ stringKey: 'value1' }, { stringKey: 'value2' }] - }; - - const cliConfig = new CliConfig(null, schema, - jsonObject, [ - { requiredKey: 1, stringKey: 'stringValue' }, - { requiredKey: 1, numberKey: 1 } - ] - ); - - expect(cliConfig.config.arrayKey2[0].stringKey).to.equal('value1'); - expect(JSON.stringify(JSON.parse(cliConfig.serialize()))).to.equal(JSON.stringify(jsonObject)); - }); -}); diff --git a/tests/models/spec-schema.d.ts b/tests/models/spec-schema.d.ts deleted file mode 100644 index 38cc0629a200..000000000000 --- a/tests/models/spec-schema.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -export interface CliConfig { - requiredKey: number; - stringKeyDefault?: string; - stringKey?: string; - booleanKey?: boolean; - numberKey?: number; - objectKey1?: { - stringKey?: string; - objectKey?: { - stringKey?: string; - }; - }; - objectKey2?: { - [name: string]: any; - stringKey?: string; - }; - arrayKey1?: any[]; - arrayKey2?: any[]; -} diff --git a/tests/models/spec-schema.json b/tests/models/spec-schema.json deleted file mode 100644 index af3648c9fc27..000000000000 --- a/tests/models/spec-schema.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - "id": "CliConfig", - "type": "object", - "properties": { - "requiredKey": { - "type": "number" - }, - "stringKeyDefault": { - "type": "string", - "default": "defaultValue" - }, - "stringKey": { - "type": "string" - }, - "booleanKey": { - "type": "boolean" - }, - "numberKey": { - "type": "number" - }, - "objectKey1": { - "type": "object", - "properties": { - "stringKey": { - "type": "string" - }, - "objectKey": { - "type": "object", - "properties": { - "stringKey": { - "type": "string" - } - } - } - } - }, - "objectKey2": { - "type": "object", - "properties": { - "stringKey": { - "type": "string" - } - }, - "additionalProperties": true - }, - "arrayKey1": { - "type": "array", - "items": { - "stringKey": { - "type": "string" - } - } - }, - "arrayKey2": { - "type": "array", - "items": { - "type": "object", - "properties": { - "stringKey": { - "type": "string" - } - } - } - } - }, - "required": ["requiredKey"] -} \ No newline at end of file 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/runner.js b/tests/runner.js deleted file mode 100644 index ab9a4e1f4955..000000000000 --- a/tests/runner.js +++ /dev/null @@ -1,22 +0,0 @@ -/* eslint-disable no-console */ -'use strict'; - -require('../lib/bootstrap-local'); - -var Mocha = require('mocha'); -var glob = require('glob'); -var path = require('path'); - -var root = 'tests/{acceptance,models}'; -var specFiles = glob.sync(root + '/**/*.spec.*'); -var mocha = new Mocha({ timeout: 5000, reporter: 'spec' }); - -process.env.CLI_ROOT = process.env.CLI_ROOT || path.resolve(__dirname, '..'); - -specFiles.forEach(mocha.addFile.bind(mocha)); - -mocha.run(function (failures) { - process.on('exit', function () { - process.exit(failures); - }); -}); diff --git a/tests/schematics/update/packages/README.md b/tests/schematics/update/packages/README.md new file mode 100644 index 000000000000..83526f2cd776 --- /dev/null +++ b/tests/schematics/update/packages/README.md @@ -0,0 +1,14 @@ + +## Update Package Group packages + +* `update-package-group-1@1.0.0` -> `update-package-group2@^1` +* `update-package-group-2@1.0.0` -> `update-package-group1@1.0.0` +---- + +* `update-package-group-1@1.0.0` -> `update-package-group2@^1` +* `update-package-group-2@1.1.0` -> `update-package-group1@1.0.0` +---- + +* `update-package-group-1@1.2.0` -> `update-package-group2@^2` +* `update-package-group-2@2.0.0` -> `update-package-group1@^1` +---- diff --git a/tests/schematics/update/packages/update-base/package.json b/tests/schematics/update/packages/update-base/package.json new file mode 100644 index 000000000000..19520c8638b3 --- /dev/null +++ b/tests/schematics/update/packages/update-base/package.json @@ -0,0 +1,5 @@ +{ + "name": "@angular-devkit-tests/update-base", + "version": "1.1.0", + "description": "Tests" +} diff --git a/tests/schematics/update/packages/update-migrations-external/package.json b/tests/schematics/update/packages/update-migrations-external/package.json new file mode 100644 index 000000000000..adaa40d219bf --- /dev/null +++ b/tests/schematics/update/packages/update-migrations-external/package.json @@ -0,0 +1,11 @@ +{ + "name": "@angular-devkit-tests/update-migrations-external", + "version": "1.6.0", + "description": "Tests", + "ng-update": { + "migrations": "@angular-devkit-tests/update-migrations/migrations.json" + }, + "dependencies": { + "@angular-devkit-tests/update-migrations": "1.6.0" + } +} diff --git a/tests/schematics/update/packages/update-migrations/migrations.json b/tests/schematics/update/packages/update-migrations/migrations.json new file mode 100644 index 000000000000..f00f3ffbbd6d --- /dev/null +++ b/tests/schematics/update/packages/update-migrations/migrations.json @@ -0,0 +1,9 @@ +{ + "schematics": { + "1": { + "factory": "./v1_5.js", + "version": "1.5", + "description": "Test." + } + } +} diff --git a/tests/schematics/update/packages/update-migrations/package.json b/tests/schematics/update/packages/update-migrations/package.json new file mode 100644 index 000000000000..d68cb22a6802 --- /dev/null +++ b/tests/schematics/update/packages/update-migrations/package.json @@ -0,0 +1,8 @@ +{ + "name": "@angular-devkit-tests/update-migrations", + "version": "1.6.0", + "description": "Tests", + "ng-update": { + "migrations": "./migrations.json" + } +} diff --git a/tests/schematics/update/packages/update-migrations/v1_5.js b/tests/schematics/update/packages/update-migrations/v1_5.js new file mode 100644 index 000000000000..becf300fecdf --- /dev/null +++ b/tests/schematics/update/packages/update-migrations/v1_5.js @@ -0,0 +1,13 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +exports.default = function () { + return function (tree) { + tree.create('/version1_5', ''); + }; +}; diff --git a/tests/schematics/update/packages/update-package-group-1/package.json b/tests/schematics/update/packages/update-package-group-1/package.json new file mode 100644 index 000000000000..58a55973dd36 --- /dev/null +++ b/tests/schematics/update/packages/update-package-group-1/package.json @@ -0,0 +1,11 @@ +{ + "name": "@angular-devkit-tests/update-package-group-1", + "version": "1.2.0", + "description": "Tests", + "ng-update": { + "packageGroup": { + "@angular-devkit-tests/update-package-group-1": "", + "@angular-devkit-tests/update-package-group-2": "^2" + } + } +} diff --git a/tests/schematics/update/packages/update-package-group-2/package.json b/tests/schematics/update/packages/update-package-group-2/package.json new file mode 100644 index 000000000000..39231fc6a190 --- /dev/null +++ b/tests/schematics/update/packages/update-package-group-2/package.json @@ -0,0 +1,11 @@ +{ + "name": "@angular-devkit-tests/update-package-group-2", + "version": "2.0.0", + "description": "Tests", + "ng-update": { + "packageGroup": { + "@angular-devkit-tests/update-package-group-1": "^1", + "@angular-devkit-tests/update-package-group-2": "" + } + } +} diff --git a/tests/schematics/update/packages/update-peer-dependencies-1/package.json b/tests/schematics/update/packages/update-peer-dependencies-1/package.json new file mode 100644 index 000000000000..4fac13f17181 --- /dev/null +++ b/tests/schematics/update/packages/update-peer-dependencies-1/package.json @@ -0,0 +1,5 @@ +{ + "name": "@angular-devkit-tests/update-peer-dependencies-1", + "version": "1.1.0", + "description": "Tests" +} diff --git a/tests/schematics/update/packages/update-peer-dependencies-2/package.json b/tests/schematics/update/packages/update-peer-dependencies-2/package.json new file mode 100644 index 000000000000..f8624d818e18 --- /dev/null +++ b/tests/schematics/update/packages/update-peer-dependencies-2/package.json @@ -0,0 +1,8 @@ +{ + "name": "@angular-devkit-tests/update-peer-dependencies-2", + "version": "1.1.0", + "description": "Tests", + "peerDependencies": { + "@angular-devkit-tests/update-peer-dependencies-2": "1.0.0" + } +} diff --git a/tests/schematics/update/packages/update-peer-dependencies-angular-5-2/package.json b/tests/schematics/update/packages/update-peer-dependencies-angular-5-2/package.json new file mode 100644 index 000000000000..dc79c9b83e52 --- /dev/null +++ b/tests/schematics/update/packages/update-peer-dependencies-angular-5-2/package.json @@ -0,0 +1,10 @@ +{ + "name": "@angular-devkit-tests/update-peer-dependencies-angular-5-2", + "version": "1.0.0", + "description": "Tests", + "peerDependencies": { + "@angular/animations": ">= 1 < 5.0.0 || >= 5.0.0-rc.0 < 6.0.0", + "@angular/compiler": ">= 1 < 5.0.0 || >= 5.0.0-rc.0 < 6.0.0", + "@angular/platform-browser": ">= 1 < 5.0.0 || >= 5.0.0-rc.0 < 6.0.0" + } +} diff --git a/tests/schematics/update/packages/update-peer-dependencies-angular-5/package.json b/tests/schematics/update/packages/update-peer-dependencies-angular-5/package.json new file mode 100644 index 000000000000..57c3fdd07db6 --- /dev/null +++ b/tests/schematics/update/packages/update-peer-dependencies-angular-5/package.json @@ -0,0 +1,8 @@ +{ + "name": "@angular-devkit-tests/update-peer-dependencies-angular-5", + "version": "1.0.0", + "description": "Tests", + "peerDependencies": { + "@angular/core": "^5.0.0" + } +} diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 000000000000..5070cc5b6927 --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig-test.json", + "compilerOptions": { "paths": {} }, + "exclude": ["e2e/assets/**"] +} diff --git a/tests/verdaccio.yaml b/tests/verdaccio.yaml new file mode 100644 index 000000000000..a398f671b43e --- /dev/null +++ b/tests/verdaccio.yaml @@ -0,0 +1,51 @@ +storage: ./storage + +auth: + auth-memory: + users: {} +listen: localhost:${HTTP_PORT} +uplinks: + npmjs: + url: https://registry.npmjs.org/ + cache: false + maxage: 20m + max_fails: 32 + timeout: 60s + agent_options: + keepAlive: true + maxSockets: 32 + maxFreeSockets: 8 + +packages: + '@angular/{build,create,cli,pwa,ssr}': + access: $all + publish: $all + + '@angular-devkit/*': + access: $all + publish: $all + + '@ngtools/webpack': + access: $all + publish: $all + + '@schematics/{angular,schematics,update}': + access: $all + publish: $all + + '@*/*': + access: $all + proxy: npmjs + + '**': + access: $all + proxy: npmjs + +log: + type: stdout + format: pretty + level: warn + +# https://github.com/verdaccio/verdaccio/issues/301 +server: + keepAliveTimeout: 0 diff --git a/tests/verdaccio_auth.yaml b/tests/verdaccio_auth.yaml new file mode 100644 index 000000000000..1cb53f5d8b5f --- /dev/null +++ b/tests/verdaccio_auth.yaml @@ -0,0 +1,33 @@ +storage: ./storage +auth: + auth-memory: + users: + testing: + name: testing + password: s3cret +listen: localhost:${HTTPS_PORT} +uplinks: + local: + url: http://localhost:${HTTP_PORT} + cache: false + maxage: 20m + max_fails: 32 + timeout: 60s + agent_options: + keepAlive: true + maxSockets: 32 + maxFreeSockets: 8 + +packages: + '**': + access: $authenticated + proxy: local + +log: + type: stdout + format: pretty + level: warn + +# https://github.com/verdaccio/verdaccio/issues/301 +server: + keepAliveTimeout: 0 diff --git a/tools/BUILD.bazel b/tools/BUILD.bazel new file mode 100644 index 000000000000..4804a9ee59a6 --- /dev/null +++ b/tools/BUILD.bazel @@ -0,0 +1,32 @@ +load("//tools:defaults.bzl", "js_binary") + +package(default_visibility = ["//visibility:public"]) + +platform( + name = "windows_x64", + constraint_values = [ + "@platforms//os:windows", + "@platforms//cpu:x86_64", + ], +) + +exports_files([ + "package_json_release_filter.jq", +]) + +js_binary( + name = "ng_cli_schema", + data = [ + "ng_cli_schema_generator.js", + ], + entry_point = "ng_cli_schema_generator.js", +) + +js_binary( + name = "quicktype_runner", + data = [ + "quicktype_runner.js", + "//:node_modules/quicktype-core", + ], + entry_point = "quicktype_runner.js", +) diff --git a/tools/bazel/BUILD.bazel b/tools/bazel/BUILD.bazel new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tools/bazel/npm_package.bzl b/tools/bazel/npm_package.bzl new file mode 100644 index 000000000000..3e2843f37946 --- /dev/null +++ b/tools/bazel/npm_package.bzl @@ -0,0 +1,125 @@ +load("@aspect_rules_js//npm:defs.bzl", _npm_package = "npm_package") +load("@bazel_lib//lib:copy_to_bin.bzl", "copy_to_bin") +load("@bazel_lib//lib:expand_template.bzl", "expand_template") +load("@bazel_lib//lib:utils.bzl", "to_label") +load("@jq.bzl//jq:jq.bzl", "jq") +load("@rules_pkg//:pkg.bzl", "pkg_tar") +load("//tools:link_package_json_to_tarballs.bzl", "link_package_json_to_tarballs") +load("//tools:snapshot_repo_filter.bzl", "SNAPSHOT_REPO_JQ_FILTER") +load("//tools:substitutions.bzl", "substitutions") + +def npm_package( + name, + deps = [], + visibility = None, + pkg_deps = [], + stamp_files = [], + pkg_json = "package.json", + extra_substitutions = {}, + replace_prefixes = {}, + **kwargs): + if name != "pkg": + fail("Expected npm_package to be named `pkg`. " + + "This is needed for pnpm workspace integration.") + + # Merge package.json with root package.json and perform various substitutions to + # prepare it for release. For jq docs, see https://stedolan.github.io/jq/manual/. + jq( + name = "basic_substitutions", + # Note: this jq filter relies on the order of the inputs + # buildifier: do not sort + srcs = ["//:package.json", pkg_json], + filter_file = "//tools:package_json_release_filter.jq", + args = ["--slurp"], + out = "substituted/package.json", + ) + + # Copy package.json files to bazel-out so we can use their bazel-out paths to determine + # the corresponding package npm package tgz path for substitutions. + copy_to_bin( + name = "package_json_copy", + srcs = [pkg_json], + ) + pkg_deps_copies = [] + for pkg_dep in pkg_deps: + pkg_label = to_label(pkg_dep) + if pkg_label.name != "package.json": + fail("ERROR: only package.json files allowed in pkg_deps of pkg_npm macro") + pkg_deps_copies.append("@@%s//%s:package_json_copy" % (pkg_label.repo_name, pkg_label.package)) + + # Substitute dependencies on other packages in this repo with tarballs. + link_package_json_to_tarballs( + name = "tar_substitutions", + src = "substituted/package.json", + pkg_deps = [":package_json_copy"] + pkg_deps_copies, + out = "substituted_with_tars/package.json", + ) + + # Substitute dependencies on other packages in this repo with snapshot repos. + jq( + name = "snapshot_repo_substitutions", + srcs = ["substituted/package.json"], + filter = SNAPSHOT_REPO_JQ_FILTER, + out = "substituted_with_snapshot_repos/package.json", + ) + + nostamp_subs = dict(substitutions["nostamp"], **extra_substitutions) + stamp_subs = dict(substitutions["stamp"], **extra_substitutions) + + expand_template( + name = "final_package_json", + template = select({ + # Do local tar substitution if config_setting is true. + "//:package_json_use_tar_deps": "substituted_with_tars/package.json", + # Do snapshot repo substitution if config_setting is true. + "//:package_json_use_snapshot_repo_deps": "substituted_with_snapshot_repos/package.json", + "//conditions:default": "substituted/package.json", + }), + out = "substituted_final/package.json", + substitutions = nostamp_subs, + stamp_substitutions = stamp_subs, + ) + + stamp_targets = [] + for f in stamp_files: + expand_template( + name = "stamp_file_%s" % f, + template = f, + out = "substituted/%s" % f, + substitutions = nostamp_subs, + stamp_substitutions = stamp_subs, + ) + + stamp_targets.append("stamp_file_%s" % f) + + _npm_package( + name = "npm_package", + visibility = visibility, + # Note: Order matters here! Last file takes precedence after replaced prefixes. + srcs = deps + stamp_targets + [":final_package_json"], + replace_prefixes = dict({ + "substituted_final/": "", + "substituted_with_tars/": "", + "substituted_with_snapshot_repos/": "", + "substituted/": "", + }, **replace_prefixes), + allow_overwrites = True, + **kwargs + ) + + # Note: For now, in hybrid mode with RNJS and RJS, we ensure + # both `:pkg` and `:npm_package` work. + native.alias( + name = "pkg", + actual = ":npm_package", + ) + + if pkg_json: + pkg_tar( + name = "npm_package_archive", + srcs = [":pkg"], + extension = "tgz", + # should not be built unless it is a dependency of another rule + tags = ["manual"], + visibility = visibility, + ) diff --git a/tools/defaults.bzl b/tools/defaults.bzl new file mode 100644 index 000000000000..20153614ac80 --- /dev/null +++ b/tools/defaults.bzl @@ -0,0 +1,98 @@ +load("@aspect_rules_jasmine//jasmine:defs.bzl", _jasmine_test = "jasmine_test") +load("@aspect_rules_js//js:defs.bzl", _js_binary = "js_binary") +load("@bazel_lib//lib:copy_to_bin.bzl", _copy_to_bin = "copy_to_bin") +load("@devinfra//bazel/ts_project:index.bzl", "strict_deps_test") +load("@rules_angular//src/ng_package:index.bzl", _ng_package = "ng_package") +load("@rules_angular//src/ts_project:index.bzl", _ts_project = "ts_project") +load("//tools:substitutions.bzl", "substitutions") +load("//tools/bazel:npm_package.bzl", _npm_package = "npm_package") + +def ts_project( + name, + deps = [], + tsconfig = None, + testonly = False, + source_map = True, + visibility = None, + **kwargs): + if tsconfig == None: + tsconfig = "//:test-tsconfig" if testonly else "//:build-tsconfig" + + package_json = kwargs.pop("package_json", None) + if not package_json and native.glob(["package.json"], allow_empty = True): + package_json = "package.json" + + _ts_project( + name = name, + testonly = testonly, + declaration = True, + source_map = source_map, + tsconfig = tsconfig, + visibility = visibility, + deps = deps, + **kwargs + ) + + strict_deps_test( + name = "%s_strict_deps_test" % name, + srcs = kwargs.get("srcs", []), + data = kwargs.get("data", []), + package_json = package_json, + tsconfig = tsconfig, + deps = deps, + ) + +def npm_package(**kwargs): + _npm_package(**kwargs) + +def copy_to_bin(**kwargs): + _copy_to_bin(**kwargs) + +def js_binary(**kwargs): + _js_binary(**kwargs) + +def ng_package(deps = [], extra_substitutions = {}, **kwargs): + nostamp_subs = dict(substitutions["nostamp"], **extra_substitutions) + stamp_subs = dict(substitutions["stamp"], **extra_substitutions) + + _ng_package( + deps = deps, + license = "//:LICENSE", + substitutions = select({ + "//:stamp": stamp_subs, + "//conditions:default": nostamp_subs, + }), + **kwargs + ) + +def jasmine_test(data = [], args = [], **kwargs): + # Create relative path to root, from current package dir. Necessary as + # we change the `chdir` below to the package directory. + relative_to_root = "/".join([".."] * len(native.package_name().split("/"))) + + # Chromium browser toolchain + env = kwargs.pop("env", {}) + env.update({ + "CHROME_BIN": "$(CHROME-HEADLESS-SHELL)", + "CHROME_PATH": "$(CHROME-HEADLESS-SHELL)", + "CHROMEDRIVER_BIN": "$(CHROMEDRIVER)", + }) + toolchains = kwargs.pop("toolchains", []) + toolchains = toolchains + ["@rules_browsers//browsers/chromium:toolchain_alias"] + data = data + ["@rules_browsers//browsers/chromium"] + + _jasmine_test( + node_modules = "//:node_modules", + chdir = native.package_name(), + env = env, + toolchains = toolchains, + args = [ + "--require=%s/node_modules/source-map-support/register.js" % relative_to_root, + # Escape so that the `js_binary` launcher triggers Bash expansion. + "'**/*+(.|_)spec.js'", + "'**/*+(.|_)spec.mjs'", + "'**/*+(.|_)spec.cjs'", + ] + args, + data = data + ["//:node_modules/source-map-support"], + **kwargs + ) diff --git a/tools/link_package_json_to_tarballs.bzl b/tools/link_package_json_to_tarballs.bzl new file mode 100644 index 000000000000..4a31cd194bf7 --- /dev/null +++ b/tools/link_package_json_to_tarballs.bzl @@ -0,0 +1,87 @@ +# Copyright Google LLC. All Rights Reserved. +# +# Use of this source code is governed by an MIT-style license that can be +# found in the LICENSE file at https://angular.dev/license +load("@bazel_lib//lib:utils.bzl", "to_label") +load("@jq.bzl//jq:jq.bzl", "jq") + +def link_package_json_to_tarballs(name, src, pkg_deps, out): + """Substitute tar paths into a package.json file for the packages it depends on. + + src and pkg_deps must be labels in the bazel-out tree for the derived path to the npm_package_archive.tgz to be correct. + + Args: + name: Name of the rule + src: package.json file to perform substitions on + pkg_deps: package.json files of dependencies to substitute + out: Output package.json file + """ + + src_pkg = to_label(src).package + + # Generate partial jq filters for each dependent package that, when run + # against a package.json file, can replace its dependency with a tar path. + filter_files = [] + for i, pkg_dep in enumerate(pkg_deps): + pkg_dep_name = "%s_%s.name" % (name, i) + pkg_dep_filter = "%s_%s.filter" % (name, i) + jq( + name = "%s_%s_name" % (name, i), + srcs = [pkg_dep], + filter = ".name", + out = pkg_dep_name, + ) + + srcs = [ + pkg_dep_name, + pkg_dep, + ] + + # Add dependent tars as srcs to include them in the dependency graph, except + # for the tar for this package as that would create a circular dependency. + pkg_label = to_label(pkg_dep) + if pkg_label.package != src_pkg: + pkg_tar = "@@%s//%s:npm_package_archive.tgz" % (pkg_label.repo_name, pkg_label.package) + srcs.append(pkg_tar) + + # Deriving the absolute path to the tar in the execroot requries different + # commands depending on whether or not the action is sandboxed. + abs_path_sandbox = "readlink $(execpath {pkg_dep})".format(pkg_dep = pkg_dep) + abs_path_nosandbox = "(cd $$(dirname $(execpath {pkg_dep})) && pwd)".format(pkg_dep = pkg_dep) + + native.genrule( + name = "%s_%s_filter" % (name, i), + srcs = srcs, + cmd = """ + TAR=$$(dirname $$({abs_path_sandbox} || {abs_path_nosandbox}))/npm_package_archive.tgz + PKGNAME=$$(cat $(execpath {pkg_name})) + if [[ "$$TAR" != *bazel-out* ]]; then + echo "ERROR: package.json passed to substitute_tar_deps must be in the output tree. You can use copy_to_bin to copy a source file to the output tree." + exit 1 + fi + echo "(..|objects|select(has($${{PKGNAME}})))[$${{PKGNAME}}] |= \\"file:$${{TAR}}\\"" > $@ + """.format( + pkg_name = pkg_dep_name, + abs_path_sandbox = abs_path_sandbox, + abs_path_nosandbox = abs_path_nosandbox, + ), + outs = [pkg_dep_filter], + ) + filter_files.append(pkg_dep_filter) + + # Combine all of the filter files into a single filter by joining with | + filter = "%s.filter" % name + native.genrule( + name = "%s_filter" % name, + srcs = filter_files, + cmd = "cat $(SRCS) | sed '$$!s#$$# |#' > $@", + outs = [filter], + ) + + # Generate final package.json with tar substitutions using the above filter + jq( + name = name, + srcs = [src], + filter_file = filter, + out = out, + ) diff --git a/tools/ng_cli_schema_generator.bzl b/tools/ng_cli_schema_generator.bzl new file mode 100644 index 000000000000..86d9552dd70c --- /dev/null +++ b/tools/ng_cli_schema_generator.bzl @@ -0,0 +1,13 @@ +load("@aspect_rules_js//js:defs.bzl", "js_run_binary") + +def cli_json_schema(name, src, out, data = []): + js_run_binary( + name = name, + outs = [out], + tags = ["schema"], + srcs = [src] + data, + tool = "//tools:ng_cli_schema", + progress_message = "Generating CLI interface from %s" % src, + mnemonic = "NgCliJsonSchema", + args = ["$(rootpath %s)" % src, "$(rootpath %s)" % out], + ) diff --git a/tools/ng_cli_schema_generator.js b/tools/ng_cli_schema_generator.js new file mode 100644 index 000000000000..88ea5531c41c --- /dev/null +++ b/tools/ng_cli_schema_generator.js @@ -0,0 +1,109 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +const { readFileSync, writeFileSync, mkdirSync } = require('fs'); +const { resolve, dirname } = require('path'); + +/** + * Generator the Angular CLI workspace schema file. + */ +function generate(inPath, outPath) { + // While on paper we could use quicktype for this. + // Quicktype doesn't handle `patternProperties` and `oneOf` that well. + + const jsonSchema = readFileSync(inPath, 'utf8'); + const nestedDefinitions = {}; + const schemaParsed = JSON.parse(jsonSchema, (key, value) => { + if (key === '$ref' && typeof value === 'string' && !value.startsWith('#')) { + // Resolve $ref and camelize key + const definitionKey = value + .replace(/(\.json|src)/g, '') + .split(/\\|\/|_|-|\./) + .filter((p) => !!p) + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) + .join(''); + + const nestedSchemaPath = resolve(dirname(inPath), value); + const nestedSchema = readFileSync(nestedSchemaPath, 'utf8'); + const nestedSchemaJson = JSON.parse(nestedSchema, (key, value) => { + switch (key) { + case '$ref': + if (value.startsWith('#/definitions/')) { + return value.replace('#/definitions/', `#/definitions/${definitionKey}/definitions/`); + } else { + throw new Error(`Error while resolving $ref ${value} in ${nestedSchemaPath}.`); + } + case '$id': + case 'id': + case '$schema': + case 'required': + case 'x-prompt': + case 'x-user-analytics': + return undefined; + default: + return value; + } + }); + + nestedDefinitions[definitionKey] = nestedSchemaJson; + + return `#/definitions/${definitionKey}`; + } + + return key === '' + ? { + ...value, + definitions: { + ...value.definitions, + ...nestedDefinitions, + }, + } + : value; + }); + + const buildWorkspaceDirectory = process.env['BUILD_WORKSPACE_DIRECTORY'] || '.'; + outPath = resolve(buildWorkspaceDirectory, outPath); + + mkdirSync(dirname(outPath), { recursive: true }); + writeFileSync( + outPath, + JSON.stringify( + schemaParsed, + (key, value) => { + if (key === 'x-deprecated') { + // Needed for IDEs, and will be replaced to 'deprecated' later on. This must be a boolean. + // https://json-schema.org/draft/2020-12/json-schema-validation.html#name-deprecated + return !!value; + } + + return value; + }, + 2, + ).replace(/"x-deprecated"/g, '"deprecated"'), + ); +} + +if (require.main === module) { + const argv = process.argv.slice(2); + if (argv.length !== 2) { + console.error('Must include 2 arguments.'); + process.exit(1); + } + + const [inPath, outPath] = argv; + + try { + generate(inPath, outPath); + } catch (error) { + console.error('An error happened:'); + console.error(error); + process.exit(127); + } +} + +exports.generate = generate; diff --git a/tools/package_json_release_filter.jq b/tools/package_json_release_filter.jq new file mode 100644 index 000000000000..4e7c3c639c67 --- /dev/null +++ b/tools/package_json_release_filter.jq @@ -0,0 +1,37 @@ +# Copyright Google LLC. All Rights Reserved. +# +# Use of this source code is governed by an MIT-style license that can be +# found in the LICENSE file at https://angular.dev/license +# +# This filter combines a subproject package.json with the root package.json +# and performs substitutions to prepare it for release. It should be called +# with the --slurp argument and be passed the root pacakge.json followed by +# the subproject package.json. +# +# See jq docs for filter syntax: https://stedolan.github.io/jq/manual/. + +.[0] as $root +| .[1] as $proj + +# Get the fields from root package.json that should override the project +# package.json, i.e., every field except the following +| ($root + | del(.bin, .description, .dependencies, .name, .main, .peerDependencies, .optionalDependencies, .typings, .version, .private, .workspaces, .resolutions, .scripts, .["ng-update"], .pnpm, .dependenciesMeta, .packageManager) +) as $root_overrides + +# Use the project package.json as a base and override other fields from root +| $proj + $root_overrides + +# Combine keywords from both +| .keywords = ($root.keywords + $proj.keywords | unique) + +# Remove devDependencies +| del(.devDependencies) + +# Add engines; versions substituted via pkg_npm ++ {"engines": {"node": "0.0.0-ENGINES-NODE", "npm": "0.0.0-ENGINES-NPM", "yarn": "0.0.0-ENGINES-YARN"}} + +# Remove all `workspace:` pnpm prefixes. Afterwards we can conveniently rely on +# substitutions from the stamp values. Note that we are doing it this way because +# substitutions can apply to multiple files, and `workspace:` can't be reliably replaced. +| walk(if type == "string" and startswith("workspace:") then sub("workspace:"; "") else . end) diff --git a/tools/quicktype_runner.js b/tools/quicktype_runner.js new file mode 100644 index 000000000000..be4745f62842 --- /dev/null +++ b/tools/quicktype_runner.js @@ -0,0 +1,179 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +const fs = require('fs'); +const path = require('path'); +const { + InputData, + JSONSchemaInput, + JSONSchemaStore, + TypeScriptTargetLanguage, + parseJSON, + quicktype, +} = require('quicktype-core'); + +/** + * This file is pure JavaScript because Bazel only support compiling to ES5, while quicktype is + * ES2015. This results in an incompatible call to `super()` in the FetchingJSONSchemaStore + * class as it tries to call JSONSchemaStore's constructor in ES5. + * TODO: move this file to typescript when Bazel supports ES2015 output. + * + * This file wraps around quicktype and can do one of two things; + * + * `node quicktype_runner.js ` + * Reads the in path and outputs the TS file at the out_path. + * + * Using `-` as the out_path will output on STDOUT instead of a file. + */ + +// Header to add to all files. +const header = ` +// THIS FILE IS AUTOMATICALLY GENERATED. TO UPDATE THIS FILE YOU NEED TO CHANGE THE +// CORRESPONDING JSON SCHEMA FILE, THEN RUN devkit-admin build (or bazel build ...). + +`; + +// Footer to add to all files. +const footer = ``; + +/** + * The simplest Node JSONSchemaStore implementation we can build which supports our custom protocol. + * Supports reading from ng-cli addresses, valid URLs and files (absolute). + */ +class FetchingJSONSchemaStore extends JSONSchemaStore { + constructor(inPath) { + super(); + this._inPath = inPath; + } + + async fetch(address) { + const URL = require('url'); + const url = URL.parse(address); + let content = null; + if (url.protocol === 'ng-cli:') { + let filePath = path.join(__dirname, '../packages/angular/cli', url.hostname, url.path); + content = fs.readFileSync(filePath, 'utf-8').trim(); + } else if (url.hostname) { + try { + const response = await fetch(address); + content = response.text(); + } catch (e) { + content = null; + } + } + + if (content === null && !path.isAbsolute(address)) { + const resolvedPath = path.join(path.dirname(this._inPath), address); + + // Check relative to inPath + if (fs.existsSync(resolvedPath)) { + content = fs.readFileSync(resolvedPath, 'utf-8'); + } + } + + if (content === null && fs.existsSync(address)) { + content = fs.readFileSync(address, 'utf-8').trim(); + } + + if (content == null) { + return undefined; + } + + content = appendDeprecatedDescription(content); + + return parseJSON(content, 'JSON Schema', address); + } +} + +/** + * Create the TS file from the schema, and overwrite the outPath (or log). + * @param {string} inPath + * @param {string} outPath + */ +async function main(inPath, outPath) { + const content = await generate(inPath); + + if (outPath === '-') { + console.log(content); + process.exit(0); + } + + const buildWorkspaceDirectory = process.env['BUILD_WORKSPACE_DIRECTORY'] || '.'; + outPath = path.resolve(buildWorkspaceDirectory, outPath); + fs.writeFileSync(outPath, content, 'utf-8'); +} + +async function generate(inPath) { + // Best description of how to use the API was found at + // https://blog.quicktype.io/customizing-quicktype/ + const inputData = new InputData(); + const content = fs.readFileSync(inPath, 'utf-8'); + const source = { name: 'Schema', schema: appendDeprecatedDescription(content) }; + + await inputData.addSource('schema', source, () => { + return new JSONSchemaInput(new FetchingJSONSchemaStore(inPath)); + }); + + const lang = new TypeScriptTargetLanguage(); + + const { lines } = await quicktype({ + lang, + inputData, + alphabetizeProperties: true, + rendererOptions: { + 'prefer-types': true, + 'just-types': true, + 'prefer-unions': false, + 'explicit-unions': true, + 'acronym-style': 'camel', + 'prefer-unknown': false, + }, + }); + + return header + lines.join('\n') + footer; +} + +/** + * Converts `x-deprecated` to `@deprecated` comments. + * @param {string} schema + */ +function appendDeprecatedDescription(schema) { + const content = JSON.parse(schema); + const props = content.properties; + + for (const key in props) { + let { description = '', 'x-deprecated': deprecated } = props[key]; + if (!deprecated) { + continue; + } + + description += '\n@deprecated' + (typeof deprecated === 'string' ? ` ${deprecated}` : ''); + props[key].description = description; + } + + return JSON.stringify(content); +} + +if (require.main === module) { + // Parse arguments and run main(). + const argv = process.argv.slice(2); + if (argv.length < 2 || argv.length > 3) { + console.error('Must include 2 or 3 arguments.'); + process.exit(1); + } + + main(argv[0], argv[1]) + .then(() => process.exit(0)) + .catch((err) => { + console.error('An error happened:'); + console.error(err); + process.exit(127); + }); +} + +exports.generate = generate; diff --git a/tools/snapshot_repo_filter.bzl b/tools/snapshot_repo_filter.bzl new file mode 100644 index 000000000000..fbd9672c07a9 --- /dev/null +++ b/tools/snapshot_repo_filter.bzl @@ -0,0 +1,28 @@ +# Copyright Google LLC. All Rights Reserved. +# +# Use of this source code is governed by an MIT-style license that can be +# found in the LICENSE file at https://angular.dev/license + +load("//:constants.bzl", "SNAPSHOT_REPOS") + +def _generate_snapshot_repo_filter(): + individual_pkg_filters = [] + for pkg_name, snapshot_repo in SNAPSHOT_REPOS.items(): + individual_pkg_filters.append( + """ + . as $root + | [paths(..)] + | [(.[] | select( + contains(["{pkg_name}"]) and + contains(["peerDependenciesMeta"]) != true))] as $paths + | $paths | reduce $paths[] as $path ($root; setpath($path; "github:{snapshot_repo}#BUILD_SCM_HASH-PLACEHOLDER")) | . + """.format( + pkg_name = pkg_name, + snapshot_repo = snapshot_repo, + ), + ) + + return " | ".join(individual_pkg_filters) + +# jq filter that replaces package.json dependencies with snapshot repos +SNAPSHOT_REPO_JQ_FILTER = _generate_snapshot_repo_filter() diff --git a/tools/substitutions.bzl b/tools/substitutions.bzl new file mode 100644 index 000000000000..dc40701971ff --- /dev/null +++ b/tools/substitutions.bzl @@ -0,0 +1,37 @@ +load( + "//:constants.bzl", + "ANGULAR_FW_PEER_DEP", + "ANGULAR_FW_VERSION", + "BASELINE_DATE", + "NG_PACKAGR_PEER_DEP", + "NG_PACKAGR_VERSION", + "RELEASE_ENGINES_NODE", + "RELEASE_ENGINES_NPM", + "RELEASE_ENGINES_YARN", +) + +_stamp_substitutions = { + # Version of the local package being built, generated via the `--workspace_status_command` flag. + "0.0.0-PLACEHOLDER": "{{STABLE_PROJECT_VERSION}}", + "0.0.0-EXPERIMENTAL-PLACEHOLDER": "{{STABLE_PROJECT_EXPERIMENTAL_VERSION}}", + # --- + "BUILD_SCM_HASH-PLACEHOLDER": "{{BUILD_SCM_ABBREV_HASH}}", + "BASELINE-DATE-PLACEHOLDER": BASELINE_DATE, + "0.0.0-ENGINES-NODE": RELEASE_ENGINES_NODE, + "0.0.0-ENGINES-NPM": RELEASE_ENGINES_NPM, + "0.0.0-ENGINES-YARN": RELEASE_ENGINES_YARN, + "0.0.0-NG-PACKAGR-VERSION": NG_PACKAGR_VERSION, + "0.0.0-NG-PACKAGR-PEER-DEP": NG_PACKAGR_PEER_DEP, + "0.0.0-ANGULAR-FW-VERSION": ANGULAR_FW_VERSION, + "0.0.0-ANGULAR-FW-PEER-DEP": ANGULAR_FW_PEER_DEP, +} + +_no_stamp_substitutions = dict(_stamp_substitutions, **{ + "0.0.0-PLACEHOLDER": "0.0.0", + "0.0.0-EXPERIMENTAL-PLACEHOLDER": "0.0.0", +}) + +substitutions = { + "stamp": _stamp_substitutions, + "nostamp": _no_stamp_substitutions, +} diff --git a/tools/test/BUILD.bazel b/tools/test/BUILD.bazel new file mode 100644 index 000000000000..71ef3e3c2794 --- /dev/null +++ b/tools/test/BUILD.bazel @@ -0,0 +1,32 @@ +load("@bazel_skylib//rules:diff_test.bzl", "diff_test") +load("@jq.bzl//jq:jq.bzl", "jq") + +jq( + name = "final_package_json", + # This jq filter relies on the order of the inputs + # buildifier: do not sort + srcs = [ + "root_package.json", + "project_package.json", + ], + args = [ + "--slurp", + ], + filter_file = "//tools:package_json_release_filter.jq", +) + +# jq outputs CR on windows https://github.com/stedolan/jq/issues/92 +# strip the CRs to do a correct comparison on all platforms +genrule( + name = "final_package_json_cr_stripped", + srcs = [":final_package_json"], + outs = ["final_package_json_cr_stripped.json"], + cmd = "cat $(execpath :final_package_json) | sed \"s#\\r##\" > $@", +) + +# Test correctness of the filter that prepares each project's package.json file for release +diff_test( + name = "package_json_filter_test", + file1 = "expected_package.json", + file2 = ":final_package_json_cr_stripped", +) diff --git a/tools/test/expected_package.json b/tools/test/expected_package.json new file mode 100644 index 000000000000..6630c9062f8a --- /dev/null +++ b/tools/test/expected_package.json @@ -0,0 +1,42 @@ +{ + "name": "project", + "version": "0.0.0-SNAPSHOT", + "description": "Project package.json", + "main": "project/index.js", + "bin": { + "projectfoo": "./bin/project-foo.js" + }, + "keywords": [ + "a", + "b", + "c" + ], + "scripts": { + "build": "node project-build-script" + }, + "repository": { + "type": "git", + "url": "https://github.com/angular/angular-cli.git" + }, + "author": "Angular Authors", + "license": "MIT", + "bugs": { + "url": "https://github.com/angular/angular-cli/issues" + }, + "homepage": "https://github.com/angular/angular-cli", + "dependencies": { + "@project/foo": "1.0.0", + "@project/bar": "2.0.0" + }, + "ng-update": { + "migrations": "@project/migration-collection.json", + "packageGroup": { + "@project/abc": "0.0.0" + } + }, + "engines": { + "node": "0.0.0-ENGINES-NODE", + "npm": "0.0.0-ENGINES-NPM", + "yarn": "0.0.0-ENGINES-YARN" + } +} diff --git a/tools/test/project_package.json b/tools/test/project_package.json new file mode 100644 index 000000000000..894c9f4cf3f2 --- /dev/null +++ b/tools/test/project_package.json @@ -0,0 +1,39 @@ +{ + "name": "project", + "version": "0.0.0-SNAPSHOT", + "description": "Project package.json", + "main": "project/index.js", + "bin": { + "projectfoo": "./bin/project-foo.js" + }, + "keywords": [ + "b", + "c" + ], + "scripts": { + "build": "node project-build-script" + }, + "repository": { + "type": "git", + "url": "https://github.com/angular/angular-cli.git" + }, + "author": "Angular Authors", + "license": "MIT", + "bugs": { + "url": "https://github.com/angular/angular-cli/issues" + }, + "homepage": "https://github.com/angular/angular-cli", + "dependencies": { + "@project/foo": "1.0.0", + "@project/bar": "2.0.0" + }, + "devDependencies": { + "@project/devdep": "1.2.3" + }, + "ng-update": { + "migrations": "@project/migration-collection.json", + "packageGroup": { + "@project/abc": "0.0.0" + } + } +} diff --git a/tools/test/root_package.json b/tools/test/root_package.json new file mode 100644 index 000000000000..e0b263aef042 --- /dev/null +++ b/tools/test/root_package.json @@ -0,0 +1,37 @@ +{ + "name": "root", + "version": "1.0.0-next.1", + "private": true, + "description": "Root package.json", + "bin": { + "root-foo": "./bin/root-foo.js", + "root-bar": "./bin/root-bar.js" + }, + "keywords": [ + "a", + "b" + ], + "scripts": { + "build": "node root-build-script" + }, + "repository": { + "type": "git", + "url": "https://github.com/angular/angular-cli.git" + }, + "author": "Angular Authors", + "license": "MIT", + "bugs": { + "url": "https://github.com/angular/angular-cli/issues" + }, + "homepage": "https://github.com/angular/angular-cli", + "workspaces": { + "packages": ["packages/root/foo/*", "packages/root/bar/*"] + }, + "resolutions": { + "root/foo/bar": "1.0.0" + }, + "devDependencies": { + "@root/foo": "1.0.0", + "@root/bar": "2.0.0" + } +} diff --git a/tools/toolchain_info.bzl b/tools/toolchain_info.bzl new file mode 100644 index 000000000000..b920c8a8f88a --- /dev/null +++ b/tools/toolchain_info.bzl @@ -0,0 +1,31 @@ +# look at the toolchains registered in the workspace file with nodejs_register_toolchains + +# the name can be anything the user wants this is just added to the target to create unique names +# the order will match against the order in the TOOLCHAIN_VERSION list. +TOOLCHAINS_NAMES = [ + "node22", + "node24", + "node26", +] + +# this is the list of toolchains that should be used and are registered with nodejs_register_toolchains in the WORKSPACE file +TOOLCHAINS_VERSIONS = [ + select({ + "@bazel_tools//src/conditions:linux_x86_64": "@node22_linux_amd64//:node_toolchain", + "@bazel_tools//src/conditions:darwin": "@node22_darwin_amd64//:node_toolchain", + "@bazel_tools//src/conditions:windows": "@node22_windows_amd64//:node_toolchain", + }), + select({ + "@bazel_tools//src/conditions:linux_x86_64": "@node24_linux_amd64//:node_toolchain", + "@bazel_tools//src/conditions:darwin": "@node24_darwin_amd64//:node_toolchain", + "@bazel_tools//src/conditions:windows": "@node24_windows_amd64//:node_toolchain", + }), + select({ + "@bazel_tools//src/conditions:linux_x86_64": "@node26_linux_amd64//:node_toolchain", + "@bazel_tools//src/conditions:darwin": "@node26_darwin_amd64//:node_toolchain", + "@bazel_tools//src/conditions:windows": "@node26_windows_amd64//:node_toolchain", + }), +] + +# A default toolchain for use when only one is necessary +DEFAULT_TOOLCHAIN_VERSION = TOOLCHAINS_VERSIONS[len(TOOLCHAINS_VERSIONS) - 1] diff --git a/tools/toolchains/BUILD.bazel b/tools/toolchains/BUILD.bazel new file mode 100644 index 000000000000..09bb94865046 --- /dev/null +++ b/tools/toolchains/BUILD.bazel @@ -0,0 +1,32 @@ +load("@rules_cc//cc:defs.bzl", "cc_toolchain") +load(":dummy_cc_toolchain.bzl", "dummy_cc_toolchain_config") + +# This defines a dummy C++ toolchain for Windows. +# Without this, the build fails with "Unable to find a CC toolchain using toolchain resolution". +dummy_cc_toolchain_config(name = "dummy_cc_toolchain_config") + +filegroup(name = "empty") + +cc_toolchain( + name = "dummy_cc_toolchain", + all_files = ":empty", + compiler_files = ":empty", + dwp_files = ":empty", + linker_files = ":empty", + objcopy_files = ":empty", + strip_files = ":empty", + supports_param_files = 0, + toolchain_config = ":dummy_cc_toolchain_config", + toolchain_identifier = "dummy_cc_toolchain", +) + +toolchain( + name = "dummy_cc_windows_no_exec_toolchain", + exec_compatible_with = [], + target_compatible_with = [ + "@platforms//os:windows", + "@platforms//cpu:x86_64", + ], + toolchain = ":dummy_cc_toolchain", + toolchain_type = "@rules_cc//cc:toolchain_type", +) diff --git a/tools/toolchains/dummy_cc_toolchain.bzl b/tools/toolchains/dummy_cc_toolchain.bzl new file mode 100644 index 000000000000..a18a9c780915 --- /dev/null +++ b/tools/toolchains/dummy_cc_toolchain.bzl @@ -0,0 +1,28 @@ +""" +This file defines a dummy C++ toolchain for Windows. +It is needed to satisfy Bazel's toolchain resolution when cross-compiling for Windows on Linux. +Some rules (e.g. rules_nodejs, js_test) or their dependencies may trigger C++ toolchain resolution +even if no actual C++ compilation is performed for the target platform. +Without this, the build fails with "Unable to find a CC toolchain using toolchain resolution". +""" + +load("@rules_cc//cc:defs.bzl", "cc_common") + +def _impl(ctx): + return cc_common.create_cc_toolchain_config_info( + ctx = ctx, + toolchain_identifier = "dummy-toolchain", + host_system_name = "local", + target_system_name = "local", + target_cpu = "x64_windows", + target_libc = "unknown", + compiler = "dummy", + abi_version = "unknown", + abi_libc_version = "unknown", + ) + +dummy_cc_toolchain_config = rule( + implementation = _impl, + attrs = {}, + provides = [CcToolchainConfigInfo], +) diff --git a/tools/ts_json_schema.bzl b/tools/ts_json_schema.bzl new file mode 100644 index 000000000000..dab651d0d7e0 --- /dev/null +++ b/tools/ts_json_schema.bzl @@ -0,0 +1,20 @@ +load("@aspect_rules_js//js:defs.bzl", "js_run_binary") + +# Generates a TS file that contains the interface for a JSON Schema file. Takes a single `src` +# argument as input, an optional data field for reference files, and produces a +# _ts_json_schema_interface() rule containing the typescript interface. +# The file produced will have the same name, with the extension replaced from `.json` to `.ts`. +# Any filename collision will be an error thrown by Bazel. +def ts_json_schema(name, src, data = []): + out = src.replace(".json", ".ts") + + js_run_binary( + name = name + ".interface", + outs = [out], + srcs = [src] + data, + tags = ["schema"], + tool = "//tools:quicktype_runner", + progress_message = "Generating TS interface for %s" % src, + mnemonic = "TsJsonSchema", + args = ["$(rootpath %s)" % src, "$(rootpath %s)" % out], + ) diff --git a/tsconfig-build-esm.json b/tsconfig-build-esm.json new file mode 100644 index 000000000000..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, abbrev@1.0.x: - version "1.0.9" - resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.0.9.tgz#91b4792588a7738c25f35dd6f63752a2f8776135" - -accepts@~1.3.3: - version "1.3.3" - resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.3.tgz#c3ca7434938648c3e0d9c1e328dd68b622c284ca" - dependencies: - mime-types "~2.1.11" - negotiator "0.6.1" - -acorn-jsx@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-3.0.1.tgz#afdf9488fb1ecefc8348f6fb22f464e32a58b36b" - dependencies: - acorn "^3.0.4" - -acorn@^3.0.4, acorn@^3.2.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-3.3.0.tgz#45e37fb39e8da3f25baee3ff5369e2bb5f22017a" - -acorn@^4.0.1: - version "4.0.3" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-4.0.3.tgz#1a3e850b428e73ba6b09d1cc527f5aaad4d03ef1" - -ajv-keywords@^1.0.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-1.2.0.tgz#676c4f087bfe1e8b12dca6fda2f3c74f417b099c" - -ajv@^4.7.0: - version "4.10.0" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-4.10.0.tgz#7ae6169180eb199192a8b9a19fd0f47fc9ac8764" - dependencies: - co "^4.6.0" - json-stable-stringify "^1.0.1" - -align-text@^0.1.1, align-text@^0.1.3: - version "0.1.4" - resolved "https://registry.yarnpkg.com/align-text/-/align-text-0.1.4.tgz#0cd90a561093f35d0a99256c22b7069433fad117" - dependencies: - kind-of "^3.0.2" - longest "^1.0.1" - repeat-string "^1.5.2" - -alphanum-sort@^1.0.1, alphanum-sort@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3" - -amdefine@1.0.0, amdefine@>=0.0.4: - version "1.0.0" - resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.0.tgz#fd17474700cb5cc9c2b709f0be9d23ce3c198c33" - -ansi-align@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-1.1.0.tgz#2f0c1658829739add5ebb15e6b0c6e3423f016ba" - dependencies: - string-width "^1.0.1" - -ansi-escapes@^1.1.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e" - -ansi-regex@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-1.1.1.tgz#41c847194646375e6a1a5d10c3ca054ef9fc980d" - -ansi-regex@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.0.0.tgz#c5061b6e0ef8a81775e50f5d66151bf6bf371107" - -ansi-styles@^2.1.0, ansi-styles@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" - -ansicolors@~0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/ansicolors/-/ansicolors-0.2.1.tgz#be089599097b74a5c9c4a84a0cdbcdb62bd87aef" - -any-promise@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" - -anymatch@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-1.3.0.tgz#a3e52fa39168c825ff57b0248126ce5a8ff95507" - dependencies: - arrify "^1.0.0" - micromatch "^2.1.5" - -aproba@^1.0.3: - version "1.0.4" - resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.0.4.tgz#2713680775e7614c8ba186c065d4e2e52d1072c0" - -are-we-there-yet@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.2.tgz#80e470e95a084794fe1899262c5667c6e88de1b3" - dependencies: - delegates "^1.0.0" - readable-stream "^2.0.0 || ^1.1.13" - -argparse@^1.0.7, argparse@~1.0.2: - version "1.0.9" - resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.9.tgz#73d83bc263f86e97f8cc4f6bae1b0e90a7d22c86" - dependencies: - sprintf-js "~1.0.2" - -arr-diff@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-2.0.0.tgz#8f3b827f955a8bd669697e4a4256ac3ceae356cf" - dependencies: - arr-flatten "^1.0.1" - -arr-flatten@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.0.1.tgz#e5ffe54d45e19f32f216e91eb99c8ce892bb604b" - -array-differ@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/array-differ/-/array-differ-1.0.0.tgz#eff52e3758249d33be402b8bb8e564bb2b5d4031" - -array-equal@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/array-equal/-/array-equal-1.0.0.tgz#8c2a5ef2472fd9ea742b04c77a75093ba2757c93" - -array-filter@~0.0.0: - version "0.0.1" - resolved "https://registry.yarnpkg.com/array-filter/-/array-filter-0.0.1.tgz#7da8cf2e26628ed732803581fd21f67cacd2eeec" - -array-find-index@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/array-find-index/-/array-find-index-1.0.2.tgz#df010aa1287e164bbda6f9723b0a96a1ec4187a1" - -array-flatten@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" - -array-ify@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/array-ify/-/array-ify-1.0.0.tgz#9e528762b4a9066ad163a6962a364418e9626ece" - -array-index@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/array-index/-/array-index-1.0.0.tgz#ec56a749ee103e4e08c790b9c353df16055b97f9" - dependencies: - debug "^2.2.0" - es6-symbol "^3.0.2" - -array-map@~0.0.0: - version "0.0.0" - resolved "https://registry.yarnpkg.com/array-map/-/array-map-0.0.0.tgz#88a2bab73d1cf7bcd5c1b118a003f66f665fa662" - -array-reduce@~0.0.0: - version "0.0.0" - resolved "https://registry.yarnpkg.com/array-reduce/-/array-reduce-0.0.0.tgz#173899d3ffd1c7d9383e4479525dbe278cab5f2b" - -array-to-error@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/array-to-error/-/array-to-error-1.1.1.tgz#d68812926d14097a205579a667eeaf1856a44c07" - dependencies: - array-to-sentence "^1.1.0" - -array-to-sentence@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/array-to-sentence/-/array-to-sentence-1.1.0.tgz#c804956dafa53232495b205a9452753a258d39fc" - -array-union@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/array-union/-/array-union-1.0.2.tgz#9a34410e4f4e3da23dea375be5be70f24778ec39" - dependencies: - array-uniq "^1.0.1" - -array-uniq@^1.0.1, array-uniq@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6" - -array-unique@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.2.1.tgz#a1d97ccafcbc2625cc70fadceb36a50c58b01a53" - -arrify@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" - -asap@~2.0.3: - version "2.0.5" - resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.5.tgz#522765b50c3510490e52d7dcfe085ef9ba96958f" - -asn1.js@^4.0.0: - version "4.9.0" - resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-4.9.0.tgz#f71a1243f3e79d46d7b07d7fbf4824ee73af054a" - dependencies: - bn.js "^4.0.0" - inherits "^2.0.1" - minimalistic-assert "^1.0.0" - -asn1@~0.2.3: - version "0.2.3" - resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.3.tgz#dac8787713c9966849fc8180777ebe9c1ddf3b86" - -assert-plus@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-0.2.0.tgz#d74e1b87e7affc0db8aadb7021f3fe48101ab234" - -assert-plus@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" - -assert@^1.1.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/assert/-/assert-1.4.1.tgz#99912d591836b5a6f5b345c0f07eefc08fc65d91" - dependencies: - util "0.10.3" - -assertion-error@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.0.2.tgz#13ca515d86206da0bac66e834dd397d87581094c" - -async-disk-cache@^1.0.0: - version "1.0.9" - resolved "https://registry.yarnpkg.com/async-disk-cache/-/async-disk-cache-1.0.9.tgz#23bafb823184f463407e474e8d5f87899f72ca63" - dependencies: - debug "^2.1.3" - istextorbinary "2.1.0" - mkdirp "^0.5.0" - rimraf "^2.5.3" - rsvp "^3.0.18" - -async-each@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.1.tgz#19d386a1d9edc6e7c1c85d388aedbcc56d33602d" - -async-foreach@^0.1.3: - version "0.1.3" - resolved "https://registry.yarnpkg.com/async-foreach/-/async-foreach-0.1.3.tgz#36121f845c0578172de419a97dbeb1d16ec34542" - -async@1.x, async@^1.3.0, async@^1.4.0, async@^1.5.0, async@^1.5.2: - version "1.5.2" - resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" - -async@2.0.0-rc.4: - version "2.0.0-rc.4" - resolved "https://registry.yarnpkg.com/async/-/async-2.0.0-rc.4.tgz#9b7f60724c17962a973f787419e0ebc5571dbad8" - dependencies: - lodash "^4.3.0" - -async@^0.9.0, async@~0.9.0: - version "0.9.2" - resolved "https://registry.yarnpkg.com/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d" - -async@^2.0.1, async@^2.1.4: - version "2.1.4" - resolved "https://registry.yarnpkg.com/async/-/async-2.1.4.tgz#2d2160c7788032e4dd6cbe2502f1f9a2c8f6cde4" - dependencies: - lodash "^4.14.0" - -async@~0.2.6: - version "0.2.10" - resolved "https://registry.yarnpkg.com/async/-/async-0.2.10.tgz#b6bbe0b0674b9d719708ca38de8c237cb526c3d1" - -asynckit@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" - -autoprefixer@^6.3.1, autoprefixer@^6.5.3: - version "6.5.4" - resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-6.5.4.tgz#1386eb6708ccff36aefff70adc694ecfd60af1b0" - dependencies: - browserslist "~1.4.0" - caniuse-db "^1.0.30000597" - normalize-range "^0.1.2" - num2fraction "^1.2.2" - postcss "^5.2.6" - postcss-value-parser "^3.2.3" - -aws-sign2@~0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.6.0.tgz#14342dd38dbcc94d0e5b87d763cd63612c0e794f" - -aws4@^1.2.1: - version "1.5.0" - resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.5.0.tgz#0a29ffb79c31c9e712eeb087e8e7a64b4a56d755" - -babel-code-frame@^6.20.0: - version "6.20.0" - resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.20.0.tgz#b968f839090f9a8bc6d41938fb96cb84f7387b26" - dependencies: - chalk "^1.1.0" - esutils "^2.0.2" - js-tokens "^2.0.0" - -babel-polyfill@^6.13.0: - version "6.20.0" - resolved "https://registry.yarnpkg.com/babel-polyfill/-/babel-polyfill-6.20.0.tgz#de4a371006139e20990aac0be367d398331204e7" - dependencies: - babel-runtime "^6.20.0" - core-js "^2.4.0" - regenerator-runtime "^0.10.0" - -babel-runtime@^6.18.0, babel-runtime@^6.20.0: - version "6.20.0" - resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.20.0.tgz#87300bdcf4cd770f09bf0048c64204e17806d16f" - dependencies: - core-js "^2.4.0" - regenerator-runtime "^0.10.0" - -balanced-match@^0.4.1, balanced-match@^0.4.2: - version "0.4.2" - resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.4.2.tgz#cb3f3e3c732dc0f01ee70b403f302e61d7709838" - -base64-js@^1.0.2: - version "1.2.0" - resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.2.0.tgz#a39992d723584811982be5e290bb6a53d86700f1" - -batch@0.5.3: - version "0.5.3" - resolved "https://registry.yarnpkg.com/batch/-/batch-0.5.3.tgz#3f3414f380321743bfc1042f9a83ff1d5824d464" - -bcrypt-pbkdf@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.0.tgz#3ca76b85241c7170bf7d9703e7b9aa74630040d4" - dependencies: - tweetnacl "^0.14.3" - -beeper@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/beeper/-/beeper-1.1.1.tgz#e6d5ea8c5dad001304a70b22638447f69cb2f809" - -big.js@^3.1.3: - version "3.1.3" - resolved "https://registry.yarnpkg.com/big.js/-/big.js-3.1.3.tgz#4cada2193652eb3ca9ec8e55c9015669c9806978" - -binary-extensions@^1.0.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.8.0.tgz#48ec8d16df4377eae5fa5884682480af4d95c774" - -"binaryextensions@1 || 2": - version "2.0.0" - resolved "https://registry.yarnpkg.com/binaryextensions/-/binaryextensions-2.0.0.tgz#e597d1a7a6a3558a2d1c7241a16c99965e6aa40f" - -blank-object@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/blank-object/-/blank-object-1.0.2.tgz#f990793fbe9a8c8dd013fb3219420bec81d5f4b9" - -block-stream@*: - version "0.0.9" - resolved "https://registry.yarnpkg.com/block-stream/-/block-stream-0.0.9.tgz#13ebfe778a03205cfe03751481ebb4b3300c126a" - dependencies: - inherits "~2.0.0" - -bluebird@^3.4.6: - version "3.4.6" - resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.4.6.tgz#01da8d821d87813d158967e743d5fe6c62cf8c0f" - -bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0: - version "4.11.6" - resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.6.tgz#53344adb14617a13f6e8dd2ce28905d1c0ba3215" - -boolbase@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" - -boom@2.x.x: - version "2.10.1" - resolved "https://registry.yarnpkg.com/boom/-/boom-2.10.1.tgz#39c8918ceff5799f83f9492a848f625add0c766f" - dependencies: - hoek "2.x.x" - -boxen@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/boxen/-/boxen-0.6.0.tgz#8364d4248ac34ff0ef1b2f2bf49a6c60ce0d81b6" - dependencies: - ansi-align "^1.1.0" - camelcase "^2.1.0" - chalk "^1.1.1" - cli-boxes "^1.0.0" - filled-array "^1.0.0" - object-assign "^4.0.1" - repeating "^2.0.0" - string-width "^1.0.1" - widest-line "^1.0.0" - -brace-expansion@^1.0.0: - version "1.1.6" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.6.tgz#7197d7eaa9b87e648390ea61fc66c84427420df9" - dependencies: - balanced-match "^0.4.1" - concat-map "0.0.1" - -braces@^1.8.2: - version "1.8.5" - resolved "https://registry.yarnpkg.com/braces/-/braces-1.8.5.tgz#ba77962e12dff969d6b76711e914b737857bf6a7" - dependencies: - expand-range "^1.8.1" - preserve "^0.2.0" - repeat-element "^1.1.2" - -broccoli-clean-css@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/broccoli-clean-css/-/broccoli-clean-css-1.1.0.tgz#9db143d9af7e0ae79c26e3ac5a9bb2d720ea19fa" - dependencies: - broccoli-persistent-filter "^1.1.6" - clean-css-promise "^0.1.0" - inline-source-map-comment "^1.0.5" - json-stable-stringify "^1.0.0" - -broccoli-funnel@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/broccoli-funnel/-/broccoli-funnel-1.1.0.tgz#dfb91a37c902456456de4a40a1881948d65b27d9" - dependencies: - array-equal "^1.0.0" - blank-object "^1.0.1" - broccoli-plugin "^1.3.0" - debug "^2.2.0" - exists-sync "0.0.4" - fast-ordered-set "^1.0.0" - fs-tree-diff "^0.5.3" - heimdalljs "^0.2.0" - minimatch "^3.0.0" - mkdirp "^0.5.0" - path-posix "^1.0.0" - rimraf "^2.4.3" - symlink-or-copy "^1.0.0" - walk-sync "^0.3.1" - -broccoli-kitchen-sink-helpers@^0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/broccoli-kitchen-sink-helpers/-/broccoli-kitchen-sink-helpers-0.3.1.tgz#77c7c18194b9664163ec4fcee2793444926e0c06" - dependencies: - glob "^5.0.10" - mkdirp "^0.5.1" - -broccoli-merge-trees@^1.0.0: - version "1.2.1" - resolved "https://registry.yarnpkg.com/broccoli-merge-trees/-/broccoli-merge-trees-1.2.1.tgz#16a7494ed56dbe61611f6c2d4817cfbaad2a3055" - dependencies: - broccoli-plugin "^1.3.0" - can-symlink "^1.0.0" - fast-ordered-set "^1.0.2" - fs-tree-diff "^0.5.4" - heimdalljs "^0.2.1" - heimdalljs-logger "^0.1.7" - rimraf "^2.4.3" - symlink-or-copy "^1.0.0" - -broccoli-persistent-filter@^1.1.6: - version "1.2.11" - resolved "https://registry.yarnpkg.com/broccoli-persistent-filter/-/broccoli-persistent-filter-1.2.11.tgz#95cc6b0b0eb0dcce5f8e6ae18f6a3cc45a06bf40" - dependencies: - async-disk-cache "^1.0.0" - blank-object "^1.0.1" - broccoli-plugin "^1.0.0" - fs-tree-diff "^0.5.2" - hash-for-dep "^1.0.2" - heimdalljs "^0.2.1" - heimdalljs-logger "^0.1.7" - md5-hex "^1.0.2" - mkdirp "^0.5.1" - promise-map-series "^0.2.1" - rsvp "^3.0.18" - symlink-or-copy "^1.0.1" - walk-sync "^0.3.1" - -broccoli-plugin@^1.0.0, broccoli-plugin@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/broccoli-plugin/-/broccoli-plugin-1.3.0.tgz#bee704a8e42da08cb58e513aaa436efb7f0ef1ee" - dependencies: - promise-map-series "^0.2.1" - quick-temp "^0.1.3" - rimraf "^2.3.4" - symlink-or-copy "^1.1.8" - -brorand@^1.0.1: - version "1.0.6" - resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.0.6.tgz#4028706b915f91f7b349a2e0bf3c376039d216e5" - -browserify-aes@^1.0.0, browserify-aes@^1.0.4: - version "1.0.6" - resolved "https://registry.yarnpkg.com/browserify-aes/-/browserify-aes-1.0.6.tgz#5e7725dbdef1fd5930d4ebab48567ce451c48a0a" - dependencies: - buffer-xor "^1.0.2" - cipher-base "^1.0.0" - create-hash "^1.1.0" - evp_bytestokey "^1.0.0" - inherits "^2.0.1" - -browserify-cipher@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/browserify-cipher/-/browserify-cipher-1.0.0.tgz#9988244874bf5ed4e28da95666dcd66ac8fc363a" - dependencies: - browserify-aes "^1.0.4" - browserify-des "^1.0.0" - evp_bytestokey "^1.0.0" - -browserify-des@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/browserify-des/-/browserify-des-1.0.0.tgz#daa277717470922ed2fe18594118a175439721dd" - dependencies: - cipher-base "^1.0.1" - des.js "^1.0.0" - inherits "^2.0.1" - -browserify-rsa@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/browserify-rsa/-/browserify-rsa-4.0.1.tgz#21e0abfaf6f2029cf2fafb133567a701d4135524" - dependencies: - bn.js "^4.1.0" - randombytes "^2.0.1" - -browserify-sign@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/browserify-sign/-/browserify-sign-4.0.0.tgz#10773910c3c206d5420a46aad8694f820b85968f" - dependencies: - bn.js "^4.1.1" - browserify-rsa "^4.0.0" - create-hash "^1.1.0" - create-hmac "^1.1.2" - elliptic "^6.0.0" - inherits "^2.0.1" - parse-asn1 "^5.0.0" - -browserify-zlib@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/browserify-zlib/-/browserify-zlib-0.1.4.tgz#bb35f8a519f600e0fa6b8485241c979d0141fb2d" - dependencies: - pako "~0.2.0" - -browserslist@~1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-1.4.0.tgz#9cfdcf5384d9158f5b70da2aa00b30e8ff019049" - dependencies: - caniuse-db "^1.0.30000539" - -buffer-shims@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/buffer-shims/-/buffer-shims-1.0.0.tgz#9978ce317388c649ad8793028c3477ef044a8b51" - -buffer-xor@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9" - -buffer@^4.3.0: - version "4.9.1" - resolved "https://registry.yarnpkg.com/buffer/-/buffer-4.9.1.tgz#6d1bb601b07a4efced97094132093027c95bc298" - dependencies: - base64-js "^1.0.2" - ieee754 "^1.1.4" - isarray "^1.0.0" - -builtin-modules@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f" - -builtin-status-codes@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-2.0.0.tgz#6f22003baacf003ccd287afe6872151fddc58579" - -bytes@2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/bytes/-/bytes-2.3.0.tgz#d5b680a165b6201739acb611542aabc2d8ceb070" - -caller-path@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-0.1.0.tgz#94085ef63581ecd3daa92444a8fe94e82577751f" - dependencies: - callsites "^0.2.0" - -callsites@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/callsites/-/callsites-0.2.0.tgz#afab96262910a7f33c19a5775825c69f34e350ca" - -camel-case@3.0.x: - version "3.0.0" - resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-3.0.0.tgz#ca3c3688a4e9cf3a4cda777dc4dcbc713249cf73" - dependencies: - no-case "^2.2.0" - upper-case "^1.1.1" - -camelcase-keys@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-2.1.0.tgz#308beeaffdf28119051efa1d932213c91b8f92e7" - dependencies: - camelcase "^2.0.0" - map-obj "^1.0.0" - -camelcase@^1.0.2: - version "1.2.1" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-1.2.1.tgz#9bb5304d2e0b56698b2c758b08a3eaa9daa58a39" - -camelcase@^2.0.0, camelcase@^2.1.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-2.1.1.tgz#7c1d16d679a1bbe59ca02cacecfb011e201f5a1f" - -camelcase@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-3.0.0.tgz#32fc4b9fcdaf845fcdf7e73bb97cac2261f0ab0a" - -can-symlink@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/can-symlink/-/can-symlink-1.0.0.tgz#97b607d8a84bb6c6e228b902d864ecb594b9d219" - dependencies: - tmp "0.0.28" - -caniuse-db@^1.0.30000539, caniuse-db@^1.0.30000597: - version "1.0.30000600" - resolved "https://registry.yarnpkg.com/caniuse-db/-/caniuse-db-1.0.30000600.tgz#2d0892f77eebb399c3c17b3ecb72da7b8740f31f" - -capture-stack-trace@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/capture-stack-trace/-/capture-stack-trace-1.0.0.tgz#4a6fa07399c26bba47f0b2496b4d0fb408c5550d" - -cardinal@^0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/cardinal/-/cardinal-0.5.0.tgz#00d5f661dbd4aabfdf7d41ce48a5a59bca35a291" - dependencies: - ansicolors "~0.2.1" - redeyed "~0.5.0" - -caseless@~0.11.0: - version "0.11.0" - resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.11.0.tgz#715b96ea9841593cc33067923f5ec60ebda4f7d7" - -center-align@^0.1.1: - version "0.1.3" - resolved "https://registry.yarnpkg.com/center-align/-/center-align-0.1.3.tgz#aa0d32629b6ee972200411cbd4461c907bc2b7ad" - dependencies: - align-text "^0.1.3" - lazy-cache "^1.0.3" - -chai@^3.5.0: - version "3.5.0" - resolved "https://registry.yarnpkg.com/chai/-/chai-3.5.0.tgz#4d02637b067fe958bdbfdd3a40ec56fef7373247" - dependencies: - assertion-error "^1.0.1" - deep-eql "^0.1.3" - type-detect "^1.0.0" - -chalk@^1.0.0, chalk@^1.1.0, chalk@^1.1.1, chalk@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" - dependencies: - ansi-styles "^2.2.1" - escape-string-regexp "^1.0.2" - has-ansi "^2.0.0" - strip-ansi "^3.0.0" - supports-color "^2.0.0" - -chokidar@^1.4.3, chokidar@^1.6.0: - version "1.6.1" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-1.6.1.tgz#2f4447ab5e96e50fb3d789fd90d4c72e0e4c70c2" - 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" - -cipher-base@^1.0.0, cipher-base@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.3.tgz#eeabf194419ce900da3018c207d212f2a6df0a07" - dependencies: - inherits "^2.0.1" - -circular-json@^0.3.0: - version "0.3.1" - resolved "https://registry.yarnpkg.com/circular-json/-/circular-json-0.3.1.tgz#be8b36aefccde8b3ca7aa2d6afc07a37242c0d2d" - -clap@^1.0.9: - version "1.1.2" - resolved "https://registry.yarnpkg.com/clap/-/clap-1.1.2.tgz#316545bf22229225a2cecaa6824cd2f56a9709ed" - dependencies: - chalk "^1.1.3" - -clean-css-promise@^0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/clean-css-promise/-/clean-css-promise-0.1.1.tgz#43f3d2c8dfcb2bf071481252cd9b76433c08eecb" - dependencies: - array-to-error "^1.0.0" - clean-css "^3.4.5" - pinkie-promise "^2.0.0" - -clean-css@3.4.x, clean-css@^3.4.5: - version "3.4.23" - resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-3.4.23.tgz#604fbbca24c12feb59b02f00b84f1fb7ded6d001" - dependencies: - commander "2.8.x" - source-map "0.4.x" - -cli-boxes@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-1.0.0.tgz#4fa917c3e59c94a004cd61f8ee509da651687143" - -cli-cursor@^1.0.1, cli-cursor@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-1.0.2.tgz#64da3f7d56a54412e59794bd62dc35295e8f2987" - dependencies: - restore-cursor "^1.0.1" - -cli-spinners@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-0.1.2.tgz#bb764d88e185fb9e1e6a2a1f19772318f605e31c" - -cli-table@^0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/cli-table/-/cli-table-0.3.1.tgz#f53b05266a8b1a0b934b3d0821e6e2dc5914ae23" - dependencies: - colors "1.0.3" - -cli-width@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.1.0.tgz#b234ca209b29ef66fc518d9b98d5847b00edf00a" - -cliui@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-2.1.0.tgz#4b475760ff80264c762c3a1719032e91c7fea0d1" - dependencies: - center-align "^0.1.1" - right-align "^0.1.1" - wordwrap "0.0.2" - -cliui@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-3.2.0.tgz#120601537a916d29940f934da3b48d585a39213d" - dependencies: - string-width "^1.0.1" - strip-ansi "^3.0.1" - wrap-ansi "^2.0.0" - -clone-stats@^0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-0.0.1.tgz#b88f94a82cf38b8791d58046ea4029ad88ca99d1" - -clone@^1.0.0, clone@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.2.tgz#260b7a99ebb1edfe247538175f783243cb19d149" - -co@^4.6.0: - version "4.6.0" - resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" - -coa@~1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/coa/-/coa-1.0.1.tgz#7f959346cfc8719e3f7233cd6852854a7c67d8a3" - dependencies: - q "^1.1.2" - -code-point-at@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" - -color-convert@^1.3.0: - version "1.8.2" - resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.8.2.tgz#be868184d7c8631766d54e7078e2672d7c7e3339" - dependencies: - color-name "^1.1.1" - -color-name@^1.0.0, color-name@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.1.tgz#4b1415304cf50028ea81643643bd82ea05803689" - -color-string@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/color-string/-/color-string-0.3.0.tgz#27d46fb67025c5c2fa25993bfbf579e47841b991" - dependencies: - color-name "^1.0.0" - -color@^0.11.0: - version "0.11.4" - resolved "https://registry.yarnpkg.com/color/-/color-0.11.4.tgz#6d7b5c74fb65e841cd48792ad1ed5e07b904d764" - dependencies: - clone "^1.0.2" - color-convert "^1.3.0" - color-string "^0.3.0" - -colormin@^1.0.5: - version "1.1.2" - resolved "https://registry.yarnpkg.com/colormin/-/colormin-1.1.2.tgz#ea2f7420a72b96881a38aae59ec124a6f7298133" - dependencies: - color "^0.11.0" - css-color-names "0.0.4" - has "^1.0.1" - -colors@1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b" - -colors@1.1.2, colors@^1.1.2, colors@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/colors/-/colors-1.1.2.tgz#168a4701756b6a7f51a12ce0c97bfa28c084ed63" - -colors@~0.6.0-1: - version "0.6.2" - resolved "https://registry.yarnpkg.com/colors/-/colors-0.6.2.tgz#2423fe6678ac0c5dae8852e5d0e5be08c997abcc" - -combined-stream@^1.0.5, combined-stream@~1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.5.tgz#938370a57b4a51dea2c77c15d5c5fdf895164009" - dependencies: - delayed-stream "~1.0.0" - -commander@0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/commander/-/commander-0.6.1.tgz#fa68a14f6a945d54dbbe50d8cdb3320e9e3b1a06" - -commander@2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-2.3.0.tgz#fd430e889832ec353b9acd1de217c11cb3eef873" - -commander@2.8.x: - version "2.8.1" - resolved "https://registry.yarnpkg.com/commander/-/commander-2.8.1.tgz#06be367febfda0c330aa1e2a072d3dc9762425d4" - dependencies: - graceful-readlink ">= 1.0.0" - -commander@2.9.x, commander@^2.9.0: - version "2.9.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-2.9.0.tgz#9c99094176e12240cb22d6c5146098400fe0f7d4" - dependencies: - graceful-readlink ">= 1.0.0" - -commander@~2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-2.1.0.tgz#d121bbae860d9992a3d517ba96f56588e47c6781" - -common-tags@^1.3.1: - version "1.4.0" - resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.4.0.tgz#1187be4f3d4cf0c0427d43f74eef1f73501614c0" - dependencies: - babel-runtime "^6.18.0" - -compare-func@^1.3.1: - version "1.3.2" - resolved "https://registry.yarnpkg.com/compare-func/-/compare-func-1.3.2.tgz#99dd0ba457e1f9bc722b12c08ec33eeab31fa648" - dependencies: - array-ify "^1.0.0" - dot-prop "^3.0.0" - -compressible@~2.0.8: - version "2.0.9" - resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.9.tgz#6daab4e2b599c2770dd9e21e7a891b1c5a755425" - dependencies: - mime-db ">= 1.24.0 < 2" - -compression@^1.5.2: - version "1.6.2" - resolved "https://registry.yarnpkg.com/compression/-/compression-1.6.2.tgz#cceb121ecc9d09c52d7ad0c3350ea93ddd402bc3" - dependencies: - accepts "~1.3.3" - bytes "2.3.0" - compressible "~2.0.8" - debug "~2.2.0" - on-headers "~1.0.1" - vary "~1.1.0" - -concat-map@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" - -concat-stream@^1.4.6: - version "1.5.2" - resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.5.2.tgz#708978624d856af41a5a741defdd261da752c266" - dependencies: - inherits "~2.0.1" - readable-stream "~2.0.0" - typedarray "~0.0.5" - -configstore@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/configstore/-/configstore-2.1.0.tgz#737a3a7036e9886102aa6099e47bb33ab1aba1a1" - dependencies: - dot-prop "^3.0.0" - graceful-fs "^4.1.2" - mkdirp "^0.5.0" - object-assign "^4.0.1" - os-tmpdir "^1.0.0" - osenv "^0.1.0" - uuid "^2.0.1" - write-file-atomic "^1.1.2" - xdg-basedir "^2.0.0" - -connect-history-api-fallback@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/connect-history-api-fallback/-/connect-history-api-fallback-1.3.0.tgz#e51d17f8f0ef0db90a64fdb47de3051556e9f169" - -console-browserify@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.1.0.tgz#f0241c45730a9fc6323b206dbf38edc741d0bb10" - dependencies: - date-now "^0.1.4" - -console-control-strings@^1.0.0, console-control-strings@~1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" - -constants-browserify@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75" - -content-disposition@0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.1.tgz#87476c6a67c8daa87e32e87616df883ba7fb071b" - -content-type@~1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.2.tgz#b7d113aee7a8dd27bd21133c4dc2529df1721eed" - -conventional-changelog-angular@^1.0.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/conventional-changelog-angular/-/conventional-changelog-angular-1.3.0.tgz#3f64185978aa13ab0954c9e46a78969fd59c6801" - dependencies: - compare-func "^1.3.1" - github-url-from-git "^1.4.0" - q "^1.4.1" - -conventional-changelog-atom@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/conventional-changelog-atom/-/conventional-changelog-atom-0.1.0.tgz#67a47c66a42b2f8909ef1587c9989ae1de730b92" - dependencies: - q "^1.4.1" - -conventional-changelog-codemirror@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/conventional-changelog-codemirror/-/conventional-changelog-codemirror-0.1.0.tgz#7577a591dbf9b538e7a150a7ee62f65a2872b334" - dependencies: - q "^1.4.1" - -conventional-changelog-core@^1.3.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/conventional-changelog-core/-/conventional-changelog-core-1.5.0.tgz#72b17509535a23d7c6cb70ad4384f74247748013" - dependencies: - conventional-changelog-writer "^1.1.0" - conventional-commits-parser "^1.0.0" - dateformat "^1.0.12" - get-pkg-repo "^1.0.0" - git-raw-commits "^1.1.0" - git-remote-origin-url "^2.0.0" - git-semver-tags "^1.1.0" - lodash "^4.0.0" - normalize-package-data "^2.3.5" - q "^1.4.1" - read-pkg "^1.1.0" - read-pkg-up "^1.0.1" - through2 "^2.0.0" - -conventional-changelog-ember@^0.2.0: - version "0.2.2" - resolved "https://registry.yarnpkg.com/conventional-changelog-ember/-/conventional-changelog-ember-0.2.2.tgz#bad70a891386bc3046484a8f4f1e5aa2dc0ad208" - dependencies: - q "^1.4.1" - -conventional-changelog-eslint@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/conventional-changelog-eslint/-/conventional-changelog-eslint-0.1.0.tgz#a52411e999e0501ce500b856b0a643d0330907e2" - dependencies: - q "^1.4.1" - -conventional-changelog-express@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/conventional-changelog-express/-/conventional-changelog-express-0.1.0.tgz#55c6c841c811962036c037bdbd964a54ae310fce" - dependencies: - q "^1.4.1" - -conventional-changelog-jquery@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/conventional-changelog-jquery/-/conventional-changelog-jquery-0.1.0.tgz#0208397162e3846986e71273b6c79c5b5f80f510" - dependencies: - q "^1.4.1" - -conventional-changelog-jscs@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/conventional-changelog-jscs/-/conventional-changelog-jscs-0.1.0.tgz#0479eb443cc7d72c58bf0bcf0ef1d444a92f0e5c" - dependencies: - q "^1.4.1" - -conventional-changelog-jshint@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/conventional-changelog-jshint/-/conventional-changelog-jshint-0.1.0.tgz#00cab8e9a3317487abd94c4d84671342918d2a07" - dependencies: - compare-func "^1.3.1" - q "^1.4.1" - -conventional-changelog-writer@^1.1.0: - version "1.4.1" - resolved "https://registry.yarnpkg.com/conventional-changelog-writer/-/conventional-changelog-writer-1.4.1.tgz#3f4cb4d003ebb56989d30d345893b52a43639c8e" - dependencies: - compare-func "^1.3.1" - conventional-commits-filter "^1.0.0" - dateformat "^1.0.11" - handlebars "^4.0.2" - json-stringify-safe "^5.0.1" - lodash "^4.0.0" - meow "^3.3.0" - semver "^5.0.1" - split "^1.0.0" - through2 "^2.0.0" - -conventional-changelog@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/conventional-changelog/-/conventional-changelog-1.1.0.tgz#8ae3fb59feb74bbee0a25833ee1f83dad4a07874" - dependencies: - conventional-changelog-angular "^1.0.0" - conventional-changelog-atom "^0.1.0" - conventional-changelog-codemirror "^0.1.0" - conventional-changelog-core "^1.3.0" - conventional-changelog-ember "^0.2.0" - conventional-changelog-eslint "^0.1.0" - conventional-changelog-express "^0.1.0" - conventional-changelog-jquery "^0.1.0" - conventional-changelog-jscs "^0.1.0" - conventional-changelog-jshint "^0.1.0" - -conventional-commits-filter@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/conventional-commits-filter/-/conventional-commits-filter-1.0.0.tgz#6fc2a659372bc3f2339cf9ffff7e1b0344b93039" - dependencies: - is-subset "^0.1.1" - modify-values "^1.0.0" - -conventional-commits-parser@^1.0.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/conventional-commits-parser/-/conventional-commits-parser-1.3.0.tgz#e327b53194e1a7ad5dc63479ee9099a52b024865" - dependencies: - JSONStream "^1.0.4" - is-text-path "^1.0.0" - lodash "^4.2.1" - meow "^3.3.0" - split2 "^2.0.0" - through2 "^2.0.0" - trim-off-newlines "^1.0.0" - -cookie-signature@1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" - -cookie@0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb" - -core-js@^2.4.0: - version "2.4.1" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.4.1.tgz#4de911e667b0eae9124e34254b53aea6fc618d3e" - -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" - -create-ecdh@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.0.tgz#888c723596cdf7612f6498233eebd7a35301737d" - dependencies: - bn.js "^4.1.0" - elliptic "^6.0.0" - -create-error-class@^3.0.1: - version "3.0.2" - resolved "https://registry.yarnpkg.com/create-error-class/-/create-error-class-3.0.2.tgz#06be7abef947a3f14a30fd610671d401bca8b7b6" - dependencies: - capture-stack-trace "^1.0.0" - -create-hash@^1.1.0, create-hash@^1.1.1: - version "1.1.2" - resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.1.2.tgz#51210062d7bb7479f6c65bb41a92208b1d61abad" - dependencies: - cipher-base "^1.0.1" - inherits "^2.0.1" - ripemd160 "^1.0.0" - sha.js "^2.3.6" - -create-hmac@^1.1.0, create-hmac@^1.1.2: - version "1.1.4" - resolved "https://registry.yarnpkg.com/create-hmac/-/create-hmac-1.1.4.tgz#d3fb4ba253eb8b3f56e39ea2fbcb8af747bd3170" - dependencies: - create-hash "^1.1.0" - inherits "^2.0.1" - -cross-spawn@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-3.0.1.tgz#1256037ecb9f0c5f79e3d6ef135e30770184b982" - dependencies: - lru-cache "^4.0.1" - which "^1.2.9" - -cross-spawn@^4.0.0: - version "4.0.2" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-4.0.2.tgz#7b9247621c23adfdd3856004a823cbe397424d41" - dependencies: - lru-cache "^4.0.1" - which "^1.2.9" - -cryptiles@2.x.x: - version "2.0.5" - resolved "https://registry.yarnpkg.com/cryptiles/-/cryptiles-2.0.5.tgz#3bdfecdc608147c1c67202fa291e7dca59eaa3b8" - dependencies: - boom "2.x.x" - -crypto-browserify@^3.11.0: - version "3.11.0" - resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.11.0.tgz#3652a0906ab9b2a7e0c3ce66a408e957a2485522" - dependencies: - browserify-cipher "^1.0.0" - browserify-sign "^4.0.0" - create-ecdh "^4.0.0" - create-hash "^1.1.0" - create-hmac "^1.1.0" - diffie-hellman "^5.0.0" - inherits "^2.0.1" - pbkdf2 "^3.0.3" - public-encrypt "^4.0.0" - randombytes "^2.0.0" - -css-color-names@0.0.4: - version "0.0.4" - resolved "https://registry.yarnpkg.com/css-color-names/-/css-color-names-0.0.4.tgz#808adc2e79cf84738069b646cb20ec27beb629e0" - -css-loader@^0.23.1: - version "0.23.1" - resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-0.23.1.tgz#9fa23f2b5c0965235910ad5ecef3b8a36390fe50" - dependencies: - css-selector-tokenizer "^0.5.1" - cssnano ">=2.6.1 <4" - loader-utils "~0.2.2" - lodash.camelcase "^3.0.1" - object-assign "^4.0.1" - postcss "^5.0.6" - postcss-modules-extract-imports "^1.0.0" - postcss-modules-local-by-default "^1.0.1" - postcss-modules-scope "^1.0.0" - postcss-modules-values "^1.1.0" - source-list-map "^0.1.4" - -css-parse@1.7.x: - version "1.7.0" - resolved "https://registry.yarnpkg.com/css-parse/-/css-parse-1.7.0.tgz#321f6cf73782a6ff751111390fc05e2c657d8c9b" - -css-select@^1.1.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/css-select/-/css-select-1.2.0.tgz#2b3a110539c5355f1cd8d314623e870b121ec858" - dependencies: - boolbase "~1.0.0" - css-what "2.1" - domutils "1.5.1" - nth-check "~1.0.1" - -css-selector-tokenizer@^0.5.1: - version "0.5.4" - resolved "https://registry.yarnpkg.com/css-selector-tokenizer/-/css-selector-tokenizer-0.5.4.tgz#139bafd34a35fd0c1428487049e0699e6f6a2c21" - dependencies: - cssesc "^0.1.0" - fastparse "^1.1.1" - -css-selector-tokenizer@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/css-selector-tokenizer/-/css-selector-tokenizer-0.6.0.tgz#6445f582c7930d241dcc5007a43d6fcb8f073152" - dependencies: - cssesc "^0.1.0" - fastparse "^1.1.1" - regexpu-core "^1.0.0" - -css-what@2.1: - version "2.1.0" - resolved "https://registry.yarnpkg.com/css-what/-/css-what-2.1.0.tgz#9467d032c38cfaefb9f2d79501253062f87fa1bd" - -cssesc@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-0.1.0.tgz#c814903e45623371a0477b40109aaafbeeaddbb4" - -"cssnano@>=2.6.1 <4": - version "3.9.1" - resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-3.9.1.tgz#41422bb5390d85a94ad4db03cc1a188bf68744fe" - dependencies: - autoprefixer "^6.3.1" - decamelize "^1.1.2" - defined "^1.0.0" - has "^1.0.1" - object-assign "^4.0.1" - postcss "^5.0.14" - postcss-calc "^5.2.0" - postcss-colormin "^2.1.8" - postcss-convert-values "^2.3.4" - postcss-discard-comments "^2.0.4" - postcss-discard-duplicates "^2.0.1" - postcss-discard-empty "^2.0.1" - postcss-discard-overridden "^0.1.1" - postcss-discard-unused "^2.2.1" - postcss-filter-plugins "^2.0.0" - postcss-merge-idents "^2.1.5" - postcss-merge-longhand "^2.0.1" - postcss-merge-rules "^2.0.3" - postcss-minify-font-values "^1.0.2" - postcss-minify-gradients "^1.0.1" - postcss-minify-params "^1.0.4" - postcss-minify-selectors "^2.0.4" - postcss-normalize-charset "^1.1.0" - postcss-normalize-url "^3.0.7" - postcss-ordered-values "^2.1.0" - postcss-reduce-idents "^2.2.2" - postcss-reduce-initial "^1.0.0" - postcss-reduce-transforms "^1.0.3" - postcss-svgo "^2.1.1" - postcss-unique-selectors "^2.0.2" - postcss-value-parser "^3.2.3" - postcss-zindex "^2.0.1" - -csso@~2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/csso/-/csso-2.2.1.tgz#51fbb5347e50e81e6ed51668a48490ae6fe2afe2" - dependencies: - clap "^1.0.9" - source-map "^0.5.3" - -currently-unhandled@^0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea" - dependencies: - array-find-index "^1.0.1" - -d@^0.1.1, d@~0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/d/-/d-0.1.1.tgz#da184c535d18d8ee7ba2aa229b914009fae11309" - dependencies: - es5-ext "~0.10.2" - -dargs@^4.0.1: - version "4.1.0" - resolved "https://registry.yarnpkg.com/dargs/-/dargs-4.1.0.tgz#03a9dbb4b5c2f139bf14ae53f0b8a2a6a86f4e17" - dependencies: - number-is-nan "^1.0.0" - -dashdash@^1.12.0: - version "1.14.1" - resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" - dependencies: - assert-plus "^1.0.0" - -date-now@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b" - -dateformat@^1.0.11, dateformat@^1.0.12: - version "1.0.12" - resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-1.0.12.tgz#9f124b67594c937ff706932e4a642cca8dbbfee9" - dependencies: - get-stdin "^4.0.1" - meow "^3.3.0" - -debug@*, debug@^2.1.0, debug@^2.1.1, debug@^2.1.3, debug@^2.2.0: - version "2.4.5" - resolved "https://registry.yarnpkg.com/debug/-/debug-2.4.5.tgz#34c7b12a1ca96674428f41fe92c49b4ce7cd0607" - dependencies: - ms "0.7.2" - -debug@2.2.0, debug@~2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/debug/-/debug-2.2.0.tgz#f87057e995b1a1f6ae6a4960664137bc56f039da" - dependencies: - ms "0.7.1" - -decamelize@^1.0.0, decamelize@^1.1.1, decamelize@^1.1.2: - version "1.2.0" - resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" - -deep-eql@^0.1.3: - version "0.1.3" - resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-0.1.3.tgz#ef558acab8de25206cd713906d74e56930eb69f2" - dependencies: - type-detect "0.1.1" - -deep-extend@^0.4.0, deep-extend@~0.4.0: - version "0.4.1" - resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.4.1.tgz#efe4113d08085f4e6f9687759810f807469e2253" - -deep-is@~0.1.3: - version "0.1.3" - resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" - -define-properties@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.2.tgz#83a73f2fea569898fb737193c8f873caf6d45c94" - dependencies: - foreach "^2.0.5" - object-keys "^1.0.8" - -defined@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.0.tgz#c98d9bcef75674188e110969151199e39b1fa693" - -del@^2.0.2: - version "2.2.2" - resolved "https://registry.yarnpkg.com/del/-/del-2.2.2.tgz#c12c981d067846c84bcaf862cff930d907ffd1a8" - dependencies: - globby "^5.0.0" - is-path-cwd "^1.0.0" - is-path-in-cwd "^1.0.0" - object-assign "^4.0.1" - pify "^2.0.0" - pinkie-promise "^2.0.0" - rimraf "^2.2.8" - -delayed-stream@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" - -delegates@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" - -denodeify@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/denodeify/-/denodeify-1.2.1.tgz#3a36287f5034e699e7577901052c2e6c94251631" - -depd@~1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.0.tgz#e1bd82c6aab6ced965b97b88b17ed3e528ca18c3" - -des.js@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.0.0.tgz#c074d2e2aa6a8a9a07dbd61f9a15c2cd83ec8ecc" - dependencies: - inherits "^2.0.1" - minimalistic-assert "^1.0.0" - -destroy@~1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80" - -diff@1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/diff/-/diff-1.4.0.tgz#7f28d2eb9ee7b15a97efd89ce63dcfdaa3ccbabf" - -diff@^2.2.2: - version "2.2.3" - resolved "https://registry.yarnpkg.com/diff/-/diff-2.2.3.tgz#60eafd0d28ee906e4e8ff0a52c1229521033bf99" - -diff@^3.0.1, diff@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/diff/-/diff-3.1.0.tgz#9406c73a401e6c2b3ba901c5e2c44eb6a60c5385" - -diffie-hellman@^5.0.0: - version "5.0.2" - resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.2.tgz#b5835739270cfe26acf632099fded2a07f209e5e" - dependencies: - bn.js "^4.1.0" - miller-rabin "^4.0.0" - randombytes "^2.0.0" - -doctrine@^1.2.2: - version "1.5.0" - resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-1.5.0.tgz#379dce730f6166f76cefa4e6707a159b02c5a6fa" - dependencies: - esutils "^2.0.2" - isarray "^1.0.0" - -dom-converter@~0.1: - version "0.1.4" - resolved "https://registry.yarnpkg.com/dom-converter/-/dom-converter-0.1.4.tgz#a45ef5727b890c9bffe6d7c876e7b19cb0e17f3b" - dependencies: - utila "~0.3" - -dom-serializer@0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.0.tgz#073c697546ce0780ce23be4a28e293e40bc30c82" - dependencies: - domelementtype "~1.1.1" - entities "~1.1.1" - -domain-browser@^1.1.1: - version "1.1.7" - resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.1.7.tgz#867aa4b093faa05f1de08c06f4d7b21fdf8698bc" - -domelementtype@1: - version "1.3.0" - resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.0.tgz#b17aed82e8ab59e52dd9c19b1756e0fc187204c2" - -domelementtype@~1.1.1: - version "1.1.3" - resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.1.3.tgz#bd28773e2642881aec51544924299c5cd822185b" - -domhandler@2.1: - version "2.1.0" - resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.1.0.tgz#d2646f5e57f6c3bab11cf6cb05d3c0acf7412594" - dependencies: - domelementtype "1" - -domutils@1.1: - version "1.1.6" - resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.1.6.tgz#bddc3de099b9a2efacc51c623f28f416ecc57485" - dependencies: - domelementtype "1" - -domutils@1.5.1: - version "1.5.1" - resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.5.1.tgz#dcd8488a26f563d61079e48c9f7b7e32373682cf" - dependencies: - dom-serializer "0" - domelementtype "1" - -dot-prop@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-3.0.0.tgz#1b708af094a49c9a0e7dbcad790aba539dac1177" - dependencies: - is-obj "^1.0.0" - -dtsgenerator@^0.7.1: - version "0.7.2" - resolved "https://registry.yarnpkg.com/dtsgenerator/-/dtsgenerator-0.7.2.tgz#1d52a469a0d8b439106b14fba3dc0c2486ebccd1" - dependencies: - babel-polyfill "^6.13.0" - commander "^2.9.0" - debug "^2.2.0" - glob "^7.0.6" - mkdirp "^0.5.1" - request "^2.75.0" - -duplexer2@0.0.2: - version "0.0.2" - resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.0.2.tgz#c614dcf67e2fb14995a91711e5a617e8a60a31db" - dependencies: - readable-stream "~1.1.9" - -duplexer2@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1" - dependencies: - readable-stream "^2.0.2" - -duplexer@~0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1" - -ecc-jsbn@~0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz#0fc73a9ed5f0d53c38193398523ef7e543777505" - dependencies: - jsbn "~0.1.0" - -editions@^1.1.1: - version "1.3.3" - resolved "https://registry.yarnpkg.com/editions/-/editions-1.3.3.tgz#0907101bdda20fac3cbe334c27cbd0688dc99a5b" - -ee-first@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" - -ejs@^2.3.4: - version "2.5.5" - resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.5.5.tgz#6ef4e954ea7dcf54f66aad2fe7aa421932d9ed77" - -elliptic@^6.0.0: - version "6.3.2" - resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.3.2.tgz#e4c81e0829cf0a65ab70e998b8232723b5c1bc48" - dependencies: - bn.js "^4.4.0" - brorand "^1.0.1" - hash.js "^1.0.0" - inherits "^2.0.1" - -ember-cli-normalize-entity-name@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/ember-cli-normalize-entity-name/-/ember-cli-normalize-entity-name-1.0.0.tgz#0b14f7bcbc599aa117b5fddc81e4fd03c4bad5b7" - dependencies: - silent-error "^1.0.0" - -ember-cli-preprocess-registry@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ember-cli-preprocess-registry/-/ember-cli-preprocess-registry-2.0.0.tgz#45c8b985eba06bb443b3abce1c3c6220fdcb8094" - dependencies: - broccoli-clean-css "^1.1.0" - broccoli-funnel "^1.0.0" - broccoli-merge-trees "^1.0.0" - debug "^2.2.0" - exists-sync "0.0.3" - lodash "^3.10.0" - process-relative-require "^1.0.0" - silent-error "^1.0.0" - -ember-cli-string-utils@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/ember-cli-string-utils/-/ember-cli-string-utils-1.0.0.tgz#d07b17d0b6223c42e09bfb835ee2b8466ec9b88e" - -emojis-list@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389" - -encodeurl@~1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.1.tgz#79e3d58655346909fe6f0f45a5de68103b294d20" - -enhanced-resolve@^2.2.0, enhanced-resolve@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-2.3.0.tgz#a115c32504b6302e85a76269d7a57ccdd962e359" - dependencies: - graceful-fs "^4.1.2" - memory-fs "^0.3.0" - object-assign "^4.0.1" - tapable "^0.2.3" - -ensure-posix-path@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/ensure-posix-path/-/ensure-posix-path-1.0.2.tgz#a65b3e42d0b71cfc585eb774f9943c8d9b91b0c2" - -entities@~1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.1.tgz#6e5c2d0a5621b5dadaecef80b90edfb5cd7772f0" - -errno@^0.1.1, errno@^0.1.3: - version "0.1.4" - resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.4.tgz#b896e23a9e5e8ba33871fc996abd3635fc9a1c7d" - dependencies: - prr "~0.0.0" - -error-ex@^1.2.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.0.tgz#e67b43f3e82c96ea3a584ffee0b9fc3325d802d9" - dependencies: - is-arrayish "^0.2.1" - -es-abstract@^1.4.3: - version "1.6.1" - resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.6.1.tgz#bb8a2064120abcf928a086ea3d9043114285ec99" - dependencies: - es-to-primitive "^1.1.1" - function-bind "^1.1.0" - is-callable "^1.1.3" - is-regex "^1.0.3" - -es-to-primitive@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.1.1.tgz#45355248a88979034b6792e19bb81f2b7975dd0d" - dependencies: - is-callable "^1.1.1" - is-date-object "^1.0.1" - is-symbol "^1.0.1" - -es5-ext@^0.10.7, es5-ext@^0.10.8, es5-ext@~0.10.11, es5-ext@~0.10.2, es5-ext@~0.10.7: - version "0.10.12" - resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.12.tgz#aa84641d4db76b62abba5e45fd805ecbab140047" - dependencies: - es6-iterator "2" - es6-symbol "~3.1" - -es6-iterator@2: - version "2.0.0" - resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.0.tgz#bd968567d61635e33c0b80727613c9cb4b096bac" - dependencies: - d "^0.1.1" - es5-ext "^0.10.7" - es6-symbol "3" - -es6-map@^0.1.3: - version "0.1.4" - resolved "https://registry.yarnpkg.com/es6-map/-/es6-map-0.1.4.tgz#a34b147be224773a4d7da8072794cefa3632b897" - dependencies: - d "~0.1.1" - es5-ext "~0.10.11" - es6-iterator "2" - es6-set "~0.1.3" - es6-symbol "~3.1.0" - event-emitter "~0.3.4" - -es6-promise@^3.0.2: - version "3.3.1" - resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.3.1.tgz#a08cdde84ccdbf34d027a1451bc91d4bcd28a613" - -es6-set@~0.1.3: - version "0.1.4" - resolved "https://registry.yarnpkg.com/es6-set/-/es6-set-0.1.4.tgz#9516b6761c2964b92ff479456233a247dc707ce8" - dependencies: - d "~0.1.1" - es5-ext "~0.10.11" - es6-iterator "2" - es6-symbol "3" - event-emitter "~0.3.4" - -es6-symbol@3, es6-symbol@^3.0.2, es6-symbol@~3.1, es6-symbol@~3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.0.tgz#94481c655e7a7cad82eba832d97d5433496d7ffa" - dependencies: - d "~0.1.1" - es5-ext "~0.10.11" - -es6-weak-map@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/es6-weak-map/-/es6-weak-map-2.0.1.tgz#0d2bbd8827eb5fb4ba8f97fbfea50d43db21ea81" - dependencies: - d "^0.1.1" - es5-ext "^0.10.8" - es6-iterator "2" - es6-symbol "3" - -escape-html@~1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" - -escape-string-regexp@1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.2.tgz#4dbc2fe674e71949caf3fb2695ce7f2dc1d9a8d1" - -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" - -escodegen@1.8.x: - version "1.8.1" - resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.8.1.tgz#5a5b53af4693110bebb0867aa3430dd3b70a1018" - dependencies: - esprima "^2.7.1" - estraverse "^1.9.1" - esutils "^2.0.2" - optionator "^0.8.1" - optionalDependencies: - source-map "~0.2.0" - -escope@^3.6.0: - version "3.6.0" - resolved "https://registry.yarnpkg.com/escope/-/escope-3.6.0.tgz#e01975e812781a163a6dadfdd80398dc64c889c3" - dependencies: - es6-map "^0.1.3" - es6-weak-map "^2.0.1" - esrecurse "^4.1.0" - estraverse "^4.1.1" - -eslint@^2.8.0: - version "2.13.1" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-2.13.1.tgz#e4cc8fa0f009fb829aaae23855a29360be1f6c11" - dependencies: - chalk "^1.1.3" - concat-stream "^1.4.6" - debug "^2.1.1" - doctrine "^1.2.2" - es6-map "^0.1.3" - escope "^3.6.0" - espree "^3.1.6" - estraverse "^4.2.0" - esutils "^2.0.2" - file-entry-cache "^1.1.1" - glob "^7.0.3" - globals "^9.2.0" - ignore "^3.1.2" - imurmurhash "^0.1.4" - inquirer "^0.12.0" - is-my-json-valid "^2.10.0" - is-resolvable "^1.0.0" - js-yaml "^3.5.1" - json-stable-stringify "^1.0.0" - levn "^0.3.0" - lodash "^4.0.0" - mkdirp "^0.5.0" - optionator "^0.8.1" - path-is-absolute "^1.0.0" - path-is-inside "^1.0.1" - pluralize "^1.2.1" - progress "^1.1.8" - require-uncached "^1.0.2" - shelljs "^0.6.0" - strip-json-comments "~1.0.1" - table "^3.7.8" - text-table "~0.2.0" - user-home "^2.0.0" - -espree@^3.1.6: - version "3.3.2" - resolved "https://registry.yarnpkg.com/espree/-/espree-3.3.2.tgz#dbf3fadeb4ecb4d4778303e50103b3d36c88b89c" - dependencies: - acorn "^4.0.1" - acorn-jsx "^3.0.0" - -esprima-fb@~12001.1.0-dev-harmony-fb: - version "12001.1.0-dev-harmony-fb" - resolved "https://registry.yarnpkg.com/esprima-fb/-/esprima-fb-12001.1.0-dev-harmony-fb.tgz#d84400384ba95ce2678c617ad24a7f40808da915" - -esprima@2.7.x, esprima@^2.6.0, esprima@^2.7.1: - version "2.7.3" - resolved "https://registry.yarnpkg.com/esprima/-/esprima-2.7.3.tgz#96e3b70d5779f6ad49cd032673d1c312767ba581" - -esrecurse@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.1.0.tgz#4713b6536adf7f2ac4f327d559e7756bff648220" - dependencies: - estraverse "~4.1.0" - object-assign "^4.0.1" - -estraverse@^1.9.1: - version "1.9.3" - resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-1.9.3.tgz#af67f2dc922582415950926091a4005d29c9bb44" - -estraverse@^4.1.1, estraverse@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.2.0.tgz#0dee3fed31fcd469618ce7342099fc1afa0bdb13" - -estraverse@~4.1.0: - version "4.1.1" - resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.1.1.tgz#f6caca728933a850ef90661d0e17982ba47111a2" - -esutils@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b" - -etag@~1.7.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/etag/-/etag-1.7.0.tgz#03d30b5f67dd6e632d2945d30d6652731a34d5d8" - -event-emitter@~0.3.4: - version "0.3.4" - resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.4.tgz#8d63ddfb4cfe1fae3b32ca265c4c720222080bb5" - dependencies: - d "~0.1.1" - es5-ext "~0.10.7" - -event-stream@~3.3.0: - version "3.3.4" - resolved "https://registry.yarnpkg.com/event-stream/-/event-stream-3.3.4.tgz#4ab4c9a0f5a54db9338b4c34d86bfce8f4b35571" - dependencies: - duplexer "~0.1.1" - from "~0" - map-stream "~0.1.0" - pause-stream "0.0.11" - split "0.3" - stream-combiner "~0.0.4" - through "~2.3.1" - -eventemitter3@1.x.x: - version "1.2.0" - resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-1.2.0.tgz#1c86991d816ad1e504750e73874224ecf3bec508" - -events@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924" - -eventsource@~0.1.6: - version "0.1.6" - resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-0.1.6.tgz#0acede849ed7dd1ccc32c811bb11b944d4f29232" - dependencies: - original ">=0.0.5" - -evp_bytestokey@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/evp_bytestokey/-/evp_bytestokey-1.0.0.tgz#497b66ad9fef65cd7c08a6180824ba1476b66e53" - dependencies: - create-hash "^1.1.1" - -exists-sync@0.0.3: - version "0.0.3" - resolved "https://registry.yarnpkg.com/exists-sync/-/exists-sync-0.0.3.tgz#b910000bedbb113b378b82f5f5a7638107622dcf" - -exists-sync@0.0.4: - version "0.0.4" - resolved "https://registry.yarnpkg.com/exists-sync/-/exists-sync-0.0.4.tgz#9744c2c428cc03b01060db454d4b12f0ef3c8879" - -exit-hook@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8" - -exit@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" - -expand-brackets@^0.1.4: - version "0.1.5" - resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-0.1.5.tgz#df07284e342a807cd733ac5af72411e581d1177b" - dependencies: - is-posix-bracket "^0.1.0" - -expand-range@^1.8.1: - version "1.8.2" - resolved "https://registry.yarnpkg.com/expand-range/-/expand-range-1.8.2.tgz#a299effd335fe2721ebae8e257ec79644fc85337" - dependencies: - fill-range "^2.1.0" - -express@^4.13.3, express@^4.14.0: - version "4.14.0" - resolved "https://registry.yarnpkg.com/express/-/express-4.14.0.tgz#c1ee3f42cdc891fb3dc650a8922d51ec847d0d66" - dependencies: - accepts "~1.3.3" - array-flatten "1.1.1" - content-disposition "0.5.1" - content-type "~1.0.2" - cookie "0.3.1" - cookie-signature "1.0.6" - debug "~2.2.0" - depd "~1.1.0" - encodeurl "~1.0.1" - escape-html "~1.0.3" - etag "~1.7.0" - finalhandler "0.5.0" - fresh "0.3.0" - merge-descriptors "1.0.1" - methods "~1.1.2" - on-finished "~2.3.0" - parseurl "~1.3.1" - path-to-regexp "0.1.7" - proxy-addr "~1.1.2" - qs "6.2.0" - range-parser "~1.2.0" - send "0.14.1" - serve-static "~1.11.1" - type-is "~1.6.13" - utils-merge "1.0.0" - vary "~1.1.0" - -extend@~3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.0.tgz#5a474353b9f3353ddd8176dfd37b91c83a46f1d4" - -extglob@^0.3.1: - version "0.3.2" - resolved "https://registry.yarnpkg.com/extglob/-/extglob-0.3.2.tgz#2e18ff3d2f49ab2765cec9023f011daa8d8349a1" - dependencies: - is-extglob "^1.0.0" - -extract-text-webpack-plugin@^2.0.0-beta.4: - version "2.0.0-beta.4" - resolved "https://registry.yarnpkg.com/extract-text-webpack-plugin/-/extract-text-webpack-plugin-2.0.0-beta.4.tgz#d32393069e7d90c8318d48392302618b56bc1ba9" - dependencies: - async "^1.5.0" - loader-utils "^0.2.3" - webpack-sources "^0.1.0" - -extsprintf@1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.0.2.tgz#e1080e0658e300b06294990cc70e1502235fd550" - -fancy-log@^1.1.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/fancy-log/-/fancy-log-1.2.0.tgz#d5a51b53e9ab22ca07d558f2b67ae55fdb5fcbd8" - dependencies: - chalk "^1.1.1" - time-stamp "^1.0.0" - -fast-levenshtein@~2.0.4: - version "2.0.5" - resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.5.tgz#bd33145744519ab1c36c3ee9f31f08e9079b67f2" - -fast-ordered-set@^1.0.0, fast-ordered-set@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/fast-ordered-set/-/fast-ordered-set-1.0.3.tgz#3fbb36634f7be79e4f7edbdb4a357dee25d184eb" - dependencies: - blank-object "^1.0.1" - -fastparse@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/fastparse/-/fastparse-1.1.1.tgz#d1e2643b38a94d7583b479060e6c4affc94071f8" - -faye-websocket@^0.10.0: - version "0.10.0" - resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.10.0.tgz#4e492f8d04dfb6f89003507f6edbf2d501e7c6f4" - dependencies: - websocket-driver ">=0.5.1" - -faye-websocket@~0.11.0: - version "0.11.0" - resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.11.0.tgz#d9ccf0e789e7db725d74bc4877d23aa42972ac50" - dependencies: - websocket-driver ">=0.5.1" - -figures@^1.3.5: - version "1.7.0" - resolved "https://registry.yarnpkg.com/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e" - dependencies: - escape-string-regexp "^1.0.5" - object-assign "^4.1.0" - -file-entry-cache@^1.1.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-1.3.1.tgz#44c61ea607ae4be9c1402f41f44270cbfe334ff8" - dependencies: - flat-cache "^1.2.1" - object-assign "^4.0.1" - -file-loader@^0.8.5: - version "0.8.5" - resolved "https://registry.yarnpkg.com/file-loader/-/file-loader-0.8.5.tgz#9275d031fe780f27d47f5f4af02bd43713cc151b" - dependencies: - loader-utils "~0.2.5" - -filename-regex@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.0.tgz#996e3e80479b98b9897f15a8a58b3d084e926775" - -fileset@0.2.x: - version "0.2.1" - resolved "https://registry.yarnpkg.com/fileset/-/fileset-0.2.1.tgz#588ef8973c6623b2a76df465105696b96aac8067" - dependencies: - glob "5.x" - minimatch "2.x" - -fill-range@^2.1.0: - version "2.2.3" - resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-2.2.3.tgz#50b77dfd7e469bc7492470963699fe7a8485a723" - dependencies: - is-number "^2.1.0" - isobject "^2.0.0" - randomatic "^1.1.3" - repeat-element "^1.1.2" - repeat-string "^1.5.2" - -filled-array@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/filled-array/-/filled-array-1.1.0.tgz#c3c4f6c663b923459a9aa29912d2d031f1507f84" - -finalhandler@0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-0.5.0.tgz#e9508abece9b6dba871a6942a1d7911b91911ac7" - dependencies: - debug "~2.2.0" - escape-html "~1.0.3" - on-finished "~2.3.0" - statuses "~1.3.0" - unpipe "~1.0.0" - -find-parent-dir@~0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/find-parent-dir/-/find-parent-dir-0.3.0.tgz#33c44b429ab2b2f0646299c5f9f718f376ff8d54" - -find-up@^1.0.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f" - dependencies: - path-exists "^2.0.0" - pinkie-promise "^2.0.0" - -findup-sync@~0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-0.3.0.tgz#37930aa5d816b777c03445e1966cc6790a4c0b16" - dependencies: - glob "~5.0.0" - -findup@0.1.5, findup@^0.1.5: - version "0.1.5" - resolved "https://registry.yarnpkg.com/findup/-/findup-0.1.5.tgz#8ad929a3393bac627957a7e5de4623b06b0e2ceb" - dependencies: - colors "~0.6.0-1" - commander "~2.1.0" - -flat-cache@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-1.2.1.tgz#6c837d6225a7de5659323740b36d5361f71691ff" - dependencies: - circular-json "^0.3.0" - del "^2.0.2" - graceful-fs "^4.1.2" - write "^0.2.1" - -flatten@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.2.tgz#dae46a9d78fbe25292258cc1e780a41d95c03782" - -for-in@^0.1.5: - version "0.1.6" - resolved "https://registry.yarnpkg.com/for-in/-/for-in-0.1.6.tgz#c9f96e89bfad18a545af5ec3ed352a1d9e5b4dc8" - -for-own@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/for-own/-/for-own-0.1.4.tgz#0149b41a39088c7515f51ebe1c1386d45f935072" - dependencies: - for-in "^0.1.5" - -foreach@^2.0.5: - version "2.0.5" - resolved "https://registry.yarnpkg.com/foreach/-/foreach-2.0.5.tgz#0bee005018aeb260d0a3af3ae658dd0136ec1b99" - -forever-agent@~0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" - -form-data@~2.1.1: - version "2.1.2" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.1.2.tgz#89c3534008b97eada4cbb157d58f6f5df025eae4" - dependencies: - asynckit "^0.4.0" - combined-stream "^1.0.5" - mime-types "^2.1.12" - -formatio@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/formatio/-/formatio-1.1.1.tgz#5ed3ccd636551097383465d996199100e86161e9" - dependencies: - samsam "~1.1" - -forwarded@~0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.0.tgz#19ef9874c4ae1c297bcf078fde63a09b66a84363" - -fresh@0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.3.0.tgz#651f838e22424e7566de161d8358caa199f83d4f" - -from@~0: - version "0.1.3" - resolved "https://registry.yarnpkg.com/from/-/from-0.1.3.tgz#ef63ac2062ac32acf7862e0d40b44b896f22f3bc" - -fs-extra@^0.16.3: - version "0.16.5" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-0.16.5.tgz#1ad661fa6c86c9608cd1b49efc6fce834939a750" - dependencies: - graceful-fs "^3.0.5" - jsonfile "^2.0.0" - rimraf "^2.2.8" - -fs-extra@^0.30.0: - version "0.30.0" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-0.30.0.tgz#f233ffcc08d4da7d432daa449776989db1df93f0" - dependencies: - graceful-fs "^4.1.2" - jsonfile "^2.1.0" - klaw "^1.0.0" - path-is-absolute "^1.0.0" - rimraf "^2.2.8" - -fs-tree-diff@^0.5.2, fs-tree-diff@^0.5.3, fs-tree-diff@^0.5.4: - version "0.5.6" - resolved "https://registry.yarnpkg.com/fs-tree-diff/-/fs-tree-diff-0.5.6.tgz#342665749e8dca406800b672268c8f5073f3e623" - dependencies: - heimdalljs-logger "^0.1.7" - object-assign "^4.1.0" - path-posix "^1.0.0" - symlink-or-copy "^1.1.8" - -fs.realpath@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" - -fsevents@^1.0.0: - version "1.0.15" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.0.15.tgz#fa63f590f3c2ad91275e4972a6cea545fb0aae44" - dependencies: - nan "^2.3.0" - node-pre-gyp "^0.6.29" - -fstream-ignore@~1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/fstream-ignore/-/fstream-ignore-1.0.5.tgz#9c31dae34767018fe1d249b24dada67d092da105" - dependencies: - fstream "^1.0.0" - inherits "2" - minimatch "^3.0.0" - -fstream@^1.0.0, fstream@^1.0.2, fstream@~1.0.10: - version "1.0.10" - resolved "https://registry.yarnpkg.com/fstream/-/fstream-1.0.10.tgz#604e8a92fe26ffd9f6fae30399d4984e1ab22822" - dependencies: - graceful-fs "^4.1.2" - inherits "~2.0.0" - mkdirp ">=0.5 0" - rimraf "2" - -function-bind@^1.0.2, function-bind@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.0.tgz#16176714c801798e4e8f2cf7f7529467bb4a5771" - -gauge@~2.6.0: - version "2.6.0" - resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.6.0.tgz#d35301ad18e96902b4751dcbbe40f4218b942a46" - dependencies: - aproba "^1.0.3" - console-control-strings "^1.0.0" - has-color "^0.1.7" - 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" - -gauge@~2.7.1: - version "2.7.2" - resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.2.tgz#15cecc31b02d05345a5d6b0e171cdb3ad2307774" - 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" - supports-color "^0.2.0" - wide-align "^1.1.0" - -gaze@^1.0.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/gaze/-/gaze-1.1.2.tgz#847224677adb8870d679257ed3388fdb61e40105" - dependencies: - globule "^1.0.0" - -generate-function@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/generate-function/-/generate-function-2.0.0.tgz#6858fe7c0969b7d4e9093337647ac79f60dfbe74" - -generate-object-property@^1.1.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/generate-object-property/-/generate-object-property-1.2.0.tgz#9c0e1c40308ce804f4783618b937fa88f99d50d0" - dependencies: - is-property "^1.0.0" - -get-caller-file@^1.0.0, get-caller-file@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.2.tgz#f702e63127e7e231c160a80c1554acb70d5047e5" - -get-pkg-repo@^1.0.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/get-pkg-repo/-/get-pkg-repo-1.3.0.tgz#43c6b4c048b75dd604fc5388edecde557f6335df" - 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" - -getpass@^0.1.1: - version "0.1.6" - resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.6.tgz#283ffd9fc1256840875311c1b60e8c40187110e6" - dependencies: - assert-plus "^1.0.0" - -git-raw-commits@^1.1.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/git-raw-commits/-/git-raw-commits-1.1.2.tgz#a12d8492aeba2881802d700825ed81c9f39e6f2f" - dependencies: - dargs "^4.0.1" - lodash.template "^4.0.2" - meow "^3.3.0" - split2 "^2.0.0" - through2 "^2.0.0" - -git-remote-origin-url@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/git-remote-origin-url/-/git-remote-origin-url-2.0.0.tgz#5282659dae2107145a11126112ad3216ec5fa65f" - dependencies: - gitconfiglocal "^1.0.0" - pify "^2.3.0" - -git-repo-info@^1.0.4: - version "1.3.1" - resolved "https://registry.yarnpkg.com/git-repo-info/-/git-repo-info-1.3.1.tgz#0c1a19ef1964b822a7230f087396af80481ce8ec" - -git-semver-tags@^1.1.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/git-semver-tags/-/git-semver-tags-1.1.2.tgz#aecf9b1b2447a6b548d48647f53edba0acad879f" - dependencies: - meow "^3.3.0" - semver "^5.0.1" - -gitconfiglocal@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/gitconfiglocal/-/gitconfiglocal-1.0.0.tgz#41d045f3851a5ea88f03f24ca1c6178114464b9b" - dependencies: - ini "^1.3.2" - -github-url-from-git@^1.4.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/github-url-from-git/-/github-url-from-git-1.5.0.tgz#f985fedcc0a9aa579dc88d7aff068d55cc6251a0" - -glob-base@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4" - dependencies: - glob-parent "^2.0.0" - is-glob "^2.0.0" - -glob-parent@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-2.0.0.tgz#81383d72db054fcccf5336daa902f182f6edbb28" - dependencies: - is-glob "^2.0.0" - -glob@3.2.11: - version "3.2.11" - resolved "https://registry.yarnpkg.com/glob/-/glob-3.2.11.tgz#4a973f635b9190f715d10987d5c00fd2815ebe3d" - dependencies: - inherits "2" - minimatch "0.3" - -glob@5.x, glob@^5.0.10, glob@~5.0.0: - version "5.0.15" - resolved "https://registry.yarnpkg.com/glob/-/glob-5.0.15.tgz#1bc936b9e02f4a603fcc222ecf7633d30b8b93b1" - dependencies: - inflight "^1.0.4" - inherits "2" - minimatch "2 || 3" - once "^1.3.0" - path-is-absolute "^1.0.0" - -glob@7.0.x: - version "7.0.6" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.0.6.tgz#211bafaf49e525b8cd93260d14ab136152b3f57a" - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.2" - once "^1.3.0" - path-is-absolute "^1.0.0" - -glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.0.6, glob@^7.1.1, glob@~7.1.1: - version "7.1.1" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.1.tgz#805211df04faaf1c63a3600306cdf5ade50b2ec8" - 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" - -globals@^9.2.0: - version "9.14.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-9.14.0.tgz#8859936af0038741263053b39d0e76ca241e4034" - -globby@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/globby/-/globby-5.0.0.tgz#ebd84667ca0dbb330b99bcfc68eac2bc54370e0d" - dependencies: - array-union "^1.0.1" - arrify "^1.0.0" - glob "^7.0.3" - object-assign "^4.0.1" - pify "^2.0.0" - pinkie-promise "^2.0.0" - -globule@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/globule/-/globule-1.1.0.tgz#c49352e4dc183d85893ee825385eb994bb6df45f" - dependencies: - glob "~7.1.1" - lodash "~4.16.4" - minimatch "~3.0.2" - -glogg@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/glogg/-/glogg-1.0.0.tgz#7fe0f199f57ac906cf512feead8f90ee4a284fc5" - dependencies: - sparkles "^1.0.0" - -got@^5.0.0: - version "5.7.1" - resolved "https://registry.yarnpkg.com/got/-/got-5.7.1.tgz#5f81635a61e4a6589f180569ea4e381680a51f35" - dependencies: - create-error-class "^3.0.1" - duplexer2 "^0.1.4" - is-redirect "^1.0.0" - is-retry-allowed "^1.0.0" - is-stream "^1.0.0" - lowercase-keys "^1.0.0" - node-status-codes "^1.0.0" - object-assign "^4.0.1" - parse-json "^2.1.0" - pinkie-promise "^2.0.0" - read-all-stream "^3.0.0" - readable-stream "^2.0.5" - timed-out "^3.0.0" - unzip-response "^1.0.2" - url-parse-lax "^1.0.0" - -graceful-fs@^3.0.5: - version "3.0.11" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-3.0.11.tgz#7613c778a1afea62f25c630a086d7f3acbbdd818" - dependencies: - natives "^1.1.0" - -graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.1.9: - version "4.1.11" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658" - -"graceful-readlink@>= 1.0.0": - version "1.0.1" - resolved "https://registry.yarnpkg.com/graceful-readlink/-/graceful-readlink-1.0.1.tgz#4cafad76bc62f02fa039b2f94e9a3dd3a391a725" - -growl@1.9.2: - version "1.9.2" - resolved "https://registry.yarnpkg.com/growl/-/growl-1.9.2.tgz#0ea7743715db8d8de2c5ede1775e1b45ac85c02f" - -gulp-util@3.0.7: - version "3.0.7" - resolved "https://registry.yarnpkg.com/gulp-util/-/gulp-util-3.0.7.tgz#78925c4b8f8b49005ac01a011c557e6218941cbb" - dependencies: - array-differ "^1.0.0" - array-uniq "^1.0.2" - beeper "^1.0.0" - chalk "^1.0.0" - dateformat "^1.0.11" - fancy-log "^1.1.0" - gulplog "^1.0.0" - has-gulplog "^0.1.0" - lodash._reescape "^3.0.0" - lodash._reevaluate "^3.0.0" - lodash._reinterpolate "^3.0.0" - lodash.template "^3.0.0" - minimist "^1.1.0" - multipipe "^0.1.2" - object-assign "^3.0.0" - replace-ext "0.0.1" - through2 "^2.0.0" - vinyl "^0.5.0" - -gulplog@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/gulplog/-/gulplog-1.0.0.tgz#e28c4d45d05ecbbed818363ce8f9c5926229ffe5" - dependencies: - glogg "^1.0.0" - -handle-thing@^1.2.4: - version "1.2.5" - resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-1.2.5.tgz#fd7aad726bf1a5fd16dfc29b2f7a6601d27139c4" - -handlebars@^4.0.1, handlebars@^4.0.2: - version "4.0.6" - resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.0.6.tgz#2ce4484850537f9c97a8026d5399b935c4ed4ed7" - dependencies: - async "^1.4.0" - optimist "^0.6.1" - source-map "^0.4.4" - optionalDependencies: - uglify-js "^2.6" - -har-validator@~2.0.6: - version "2.0.6" - resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-2.0.6.tgz#cdcbc08188265ad119b6a5a7c8ab70eecfb5d27d" - dependencies: - chalk "^1.1.1" - commander "^2.9.0" - is-my-json-valid "^2.12.4" - pinkie-promise "^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" - dependencies: - ansi-regex "^2.0.0" - -has-color@^0.1.7: - version "0.1.7" - resolved "https://registry.yarnpkg.com/has-color/-/has-color-0.1.7.tgz#67144a5260c34fc3cca677d041daf52fe7b78b2f" - -has-flag@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-1.0.0.tgz#9d9e793165ce017a00f00418c43f942a7b1d11fa" - -has-gulplog@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/has-gulplog/-/has-gulplog-0.1.0.tgz#6414c82913697da51590397dafb12f22967811ce" - dependencies: - sparkles "^1.0.0" - -has-unicode@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" - -has@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/has/-/has-1.0.1.tgz#8461733f538b0837c9361e39a9ab9e9704dc2f28" - dependencies: - function-bind "^1.0.2" - -hash-for-dep@^1.0.2: - version "1.0.4" - resolved "https://registry.yarnpkg.com/hash-for-dep/-/hash-for-dep-1.0.4.tgz#8774e7b80c7c07be3bdc58b55278a4c6865d3737" - dependencies: - broccoli-kitchen-sink-helpers "^0.3.1" - heimdalljs "^0.2.3" - resolve "^1.1.6" - -hash.js@^1.0.0: - version "1.0.3" - resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.0.3.tgz#1332ff00156c0a0ffdd8236013d07b77a0451573" - dependencies: - inherits "^2.0.1" - -hawk@~3.1.3: - version "3.1.3" - resolved "https://registry.yarnpkg.com/hawk/-/hawk-3.1.3.tgz#078444bd7c1640b0fe540d2c9b73d59678e8e1c4" - dependencies: - boom "2.x.x" - cryptiles "2.x.x" - hoek "2.x.x" - sntp "1.x.x" - -he@1.1.x: - version "1.1.0" - resolved "https://registry.yarnpkg.com/he/-/he-1.1.0.tgz#29319d49beec13a9b1f3c4f9b2a6dde4859bb2a7" - -heimdalljs-logger@^0.1.7: - version "0.1.7" - resolved "https://registry.yarnpkg.com/heimdalljs-logger/-/heimdalljs-logger-0.1.7.tgz#10e340af5c22a811e34522d9b9397675ad589ca4" - dependencies: - debug "^2.2.0" - heimdalljs "^0.2.0" - -heimdalljs@^0.2.0, heimdalljs@^0.2.1, heimdalljs@^0.2.3: - version "0.2.3" - resolved "https://registry.yarnpkg.com/heimdalljs/-/heimdalljs-0.2.3.tgz#35b82a6a4d73541fc4fb88d2fe2b23608fb4f779" - dependencies: - rsvp "~3.2.1" - -hoek@2.x.x: - version "2.16.3" - resolved "https://registry.yarnpkg.com/hoek/-/hoek-2.16.3.tgz#20bb7403d3cea398e91dc4710a8ff1b8274a25ed" - -hosted-git-info@^2.1.4: - version "2.1.5" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.1.5.tgz#0ba81d90da2e25ab34a332e6ec77936e1598118b" - -hpack.js@^2.1.6: - version "2.1.6" - resolved "https://registry.yarnpkg.com/hpack.js/-/hpack.js-2.1.6.tgz#87774c0949e513f42e84575b3c45681fade2a0b2" - dependencies: - inherits "^2.0.1" - obuf "^1.0.0" - readable-stream "^2.0.1" - wbuf "^1.1.0" - -html-comment-regex@^1.1.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/html-comment-regex/-/html-comment-regex-1.1.1.tgz#668b93776eaae55ebde8f3ad464b307a4963625e" - -html-minifier@^3.1.0: - version "3.2.3" - resolved "https://registry.yarnpkg.com/html-minifier/-/html-minifier-3.2.3.tgz#d2ff536e24d95726c332493d8f77d84dbed85372" - dependencies: - camel-case "3.0.x" - clean-css "3.4.x" - commander "2.9.x" - he "1.1.x" - ncname "1.0.x" - param-case "2.1.x" - relateurl "0.2.x" - uglify-js "2.7.x" - -html-webpack-plugin@^2.19.0: - version "2.24.1" - resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-2.24.1.tgz#7f45fc678f66eac2d433f22336b4399da023b57e" - dependencies: - bluebird "^3.4.6" - html-minifier "^3.1.0" - loader-utils "^0.2.16" - lodash "^4.16.4" - pretty-error "^2.0.2" - toposort "^1.0.0" - -htmlparser2@~3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.3.0.tgz#cc70d05a59f6542e43f0e685c982e14c924a9efe" - dependencies: - domelementtype "1" - domhandler "2.1" - domutils "1.1" - readable-stream "1.0" - -http-deceiver@^1.2.4: - version "1.2.7" - resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87" - -http-errors@~1.5.0: - version "1.5.1" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.5.1.tgz#788c0d2c1de2c81b9e6e8c01843b6b97eb920750" - dependencies: - inherits "2.0.3" - setprototypeof "1.0.2" - statuses ">= 1.3.1 < 2" - -http-proxy-middleware@~0.17.1: - version "0.17.3" - resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-0.17.3.tgz#940382147149b856084f5534752d5b5a8168cd1d" - dependencies: - http-proxy "^1.16.2" - is-glob "^3.1.0" - lodash "^4.17.2" - micromatch "^2.3.11" - -http-proxy@^1.16.2: - version "1.16.2" - resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.16.2.tgz#06dff292952bf64dbe8471fa9df73066d4f37742" - dependencies: - eventemitter3 "1.x.x" - requires-port "1.x.x" - -http-signature@~1.1.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.1.1.tgz#df72e267066cd0ac67fb76adf8e134a8fbcf91bf" - dependencies: - assert-plus "^0.2.0" - jsprim "^1.2.2" - sshpk "^1.7.0" - -https-browserify@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-0.0.1.tgz#3f91365cabe60b77ed0ebba24b454e3e09d95a82" - -icss-replace-symbols@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.0.2.tgz#cb0b6054eb3af6edc9ab1d62d01933e2d4c8bfa5" - -ieee754@^1.1.4: - version "1.1.8" - resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.8.tgz#be33d40ac10ef1926701f6f08a2d86fbfd1ad3e4" - -ignore@^3.1.2: - version "3.2.0" - resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.2.0.tgz#8d88f03c3002a0ac52114db25d2c673b0bf1e435" - -image-size@~0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/image-size/-/image-size-0.5.0.tgz#be7aed1c37b5ac3d9ba1d66a24b4c47ff8397651" - -imurmurhash@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" - -in-publish@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/in-publish/-/in-publish-2.0.0.tgz#e20ff5e3a2afc2690320b6dc552682a9c7fadf51" - -indent-string@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-2.1.0.tgz#8e2d48348742121b4a8218b7a137e9a52049dc80" - dependencies: - repeating "^2.0.0" - -indexes-of@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/indexes-of/-/indexes-of-1.0.1.tgz#f30f716c8e2bd346c7b67d3df3915566a7c05607" - -indexof@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d" - -inflection@^1.7.0: - version "1.10.0" - resolved "https://registry.yarnpkg.com/inflection/-/inflection-1.10.0.tgz#5bffcb1197ad3e81050f8e17e21668087ee9eb2f" - -inflight@^1.0.4: - version "1.0.6" - resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" - dependencies: - once "^1.3.0" - wrappy "1" - -inherits@2, inherits@2.0.3, inherits@^2.0.1, inherits@~2.0.0, inherits@~2.0.1: - version "2.0.3" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" - -inherits@2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.1.tgz#b17d08d326b4423e568eff719f91b0b1cbdf69f1" - -ini@^1.3.2, ini@~1.3.0: - version "1.3.4" - resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.4.tgz#0537cb79daf59b59a1a517dff706c86ec039162e" - -inline-source-map-comment@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/inline-source-map-comment/-/inline-source-map-comment-1.0.5.tgz#50a8a44c2a790dfac441b5c94eccd5462635faf6" - dependencies: - chalk "^1.0.0" - get-stdin "^4.0.1" - minimist "^1.1.1" - sum-up "^1.0.1" - xtend "^4.0.0" - -inquirer@^0.12.0: - version "0.12.0" - resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-0.12.0.tgz#1ef2bfd63504df0bc75785fff8c2c41df12f077e" - dependencies: - ansi-escapes "^1.1.0" - ansi-regex "^2.0.0" - chalk "^1.0.0" - cli-cursor "^1.0.1" - cli-width "^2.0.0" - figures "^1.3.5" - lodash "^4.3.0" - readline2 "^1.0.1" - run-async "^0.1.0" - rx-lite "^3.1.2" - string-width "^1.0.1" - strip-ansi "^3.0.0" - through "^2.3.6" - -interpret@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.0.1.tgz#d579fb7f693b858004947af39fa0db49f795602c" - -invert-kv@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6" - -ipaddr.js@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.1.1.tgz#c791d95f52b29c1247d5df80ada39b8a73647230" - -is-absolute-url@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-2.1.0.tgz#50530dfb84fcc9aa7dbe7852e83a37b93b9f2aa6" - -is-arrayish@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" - -is-binary-path@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898" - dependencies: - binary-extensions "^1.0.0" - -is-buffer@^1.0.2: - version "1.1.4" - resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.4.tgz#cfc86ccd5dc5a52fa80489111c6920c457e2d98b" - -is-builtin-module@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-1.0.0.tgz#540572d34f7ac3119f8f76c30cbc1b1e037affbe" - dependencies: - builtin-modules "^1.0.0" - -is-callable@^1.1.1, is-callable@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.3.tgz#86eb75392805ddc33af71c92a0eedf74ee7604b2" - -is-date-object@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.1.tgz#9aa20eb6aeebbff77fbd33e74ca01b33581d3a16" - -is-dotfile@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.2.tgz#2c132383f39199f8edc268ca01b9b007d205cc4d" - -is-equal-shallow@^0.1.3: - version "0.1.3" - resolved "https://registry.yarnpkg.com/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz#2238098fc221de0bcfa5d9eac4c45d638aa1c534" - dependencies: - is-primitive "^2.0.0" - -is-extendable@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" - -is-extglob@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-1.0.0.tgz#ac468177c4943405a092fc8f29760c6ffc6206c0" - -is-extglob@^2.1.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" - -is-finite@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-finite/-/is-finite-1.0.2.tgz#cc6677695602be550ef11e8b4aa6305342b6d0aa" - dependencies: - number-is-nan "^1.0.0" - -is-fullwidth-code-point@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" - dependencies: - number-is-nan "^1.0.0" - -is-fullwidth-code-point@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" - -is-git-url@^0.2.0: - version "0.2.3" - resolved "https://registry.yarnpkg.com/is-git-url/-/is-git-url-0.2.3.tgz#445200d6fbd6da028fb5e01440d9afc93f3ccb64" - -is-glob@^2.0.0, is-glob@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-2.0.1.tgz#d096f926a3ded5600f3fdfd91198cb0888c2d863" - dependencies: - is-extglob "^1.0.0" - -is-glob@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a" - dependencies: - is-extglob "^2.1.0" - -is-my-json-valid@^2.10.0, is-my-json-valid@^2.12.4: - version "2.15.0" - resolved "https://registry.yarnpkg.com/is-my-json-valid/-/is-my-json-valid-2.15.0.tgz#936edda3ca3c211fd98f3b2d3e08da43f7b2915b" - dependencies: - generate-function "^2.0.0" - generate-object-property "^1.1.0" - jsonpointer "^4.0.0" - xtend "^4.0.0" - -is-npm@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-1.0.0.tgz#f2fb63a65e4905b406c86072765a1a4dc793b9f4" - -is-number@^2.0.2, is-number@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f" - dependencies: - kind-of "^3.0.2" - -is-obj@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f" - -is-path-cwd@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-path-cwd/-/is-path-cwd-1.0.0.tgz#d225ec23132e89edd38fda767472e62e65f1106d" - -is-path-in-cwd@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-path-in-cwd/-/is-path-in-cwd-1.0.0.tgz#6477582b8214d602346094567003be8a9eac04dc" - dependencies: - is-path-inside "^1.0.0" - -is-path-inside@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-1.0.0.tgz#fc06e5a1683fbda13de667aff717bbc10a48f37f" - dependencies: - path-is-inside "^1.0.1" - -is-plain-obj@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" - -is-posix-bracket@^0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz#3334dc79774368e92f016e6fbc0a88f5cd6e6bc4" - -is-primitive@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-primitive/-/is-primitive-2.0.0.tgz#207bab91638499c07b2adf240a41a87210034575" - -is-property@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-property/-/is-property-1.0.2.tgz#57fe1c4e48474edd65b09911f26b1cd4095dda84" - -is-redirect@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-redirect/-/is-redirect-1.0.0.tgz#1d03dded53bd8db0f30c26e4f95d36fc7c87dc24" - -is-regex@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.0.3.tgz#0d55182bddf9f2fde278220aec3a75642c908637" - -is-resolvable@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-resolvable/-/is-resolvable-1.0.0.tgz#8df57c61ea2e3c501408d100fb013cf8d6e0cc62" - dependencies: - tryit "^1.0.1" - -is-retry-allowed@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-retry-allowed/-/is-retry-allowed-1.1.0.tgz#11a060568b67339444033d0125a61a20d564fb34" - -is-stream@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" - -is-subset@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/is-subset/-/is-subset-0.1.1.tgz#8a59117d932de1de00f245fcdd39ce43f1e939a6" - -is-svg@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-svg/-/is-svg-2.1.0.tgz#cf61090da0d9efbcab8722deba6f032208dbb0e9" - dependencies: - html-comment-regex "^1.1.0" - -is-symbol@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.1.tgz#3cc59f00025194b6ab2e38dbae6689256b660572" - -is-text-path@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-text-path/-/is-text-path-1.0.1.tgz#4e1aa0fb51bfbcb3e92688001397202c1775b66e" - dependencies: - text-extensions "^1.0.0" - -is-typedarray@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" - -is-utf8@^0.2.0: - version "0.2.1" - resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72" - -isarray@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" - -isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" - -isbinaryfile@^2.0.3: - version "2.0.4" - resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-2.0.4.tgz#d23592e6a6f093efb84c2e6152056be294e414a1" - -isexe@^1.1.1: - version "1.1.2" - resolved "https://registry.yarnpkg.com/isexe/-/isexe-1.1.2.tgz#36f3e22e60750920f5e7241a476a8c6a42275ad0" - -isobject@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" - dependencies: - isarray "1.0.0" - -isstream@~0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" - -istanbul@0.4.3, istanbul@0.x.x: - version "0.4.3" - resolved "https://registry.yarnpkg.com/istanbul/-/istanbul-0.4.3.tgz#5b714ee0ae493ac5ef204b99f3872bceef73d53a" - dependencies: - abbrev "1.0.x" - async "1.x" - escodegen "1.8.x" - esprima "2.7.x" - fileset "0.2.x" - 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" - -istextorbinary@2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/istextorbinary/-/istextorbinary-2.1.0.tgz#dbed2a6f51be2f7475b68f89465811141b758874" - dependencies: - binaryextensions "1 || 2" - editions "^1.1.1" - textextensions "1 || 2" - -jade@0.26.3: - version "0.26.3" - resolved "https://registry.yarnpkg.com/jade/-/jade-0.26.3.tgz#8f10d7977d8d79f2f6ff862a81b0513ccb25686c" - dependencies: - commander "0.6.1" - mkdirp "0.3.0" - -jasmine-core@~2.5.2: - version "2.5.2" - resolved "https://registry.yarnpkg.com/jasmine-core/-/jasmine-core-2.5.2.tgz#6f61bd79061e27f43e6f9355e44b3c6cab6ff297" - -jasmine-spec-reporter@^2.7.0: - version "2.7.0" - resolved "https://registry.yarnpkg.com/jasmine-spec-reporter/-/jasmine-spec-reporter-2.7.0.tgz#42907ff889952a129c0afc2929e195f4e74c98ff" - dependencies: - colors "1.1.2" - -jasmine@^2.4.1: - version "2.5.2" - resolved "https://registry.yarnpkg.com/jasmine/-/jasmine-2.5.2.tgz#6283cef7085c095cc25d651e954df004f7e3e421" - dependencies: - exit "^0.1.2" - glob "^7.0.6" - jasmine-core "~2.5.2" - -jodid25519@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/jodid25519/-/jodid25519-1.0.2.tgz#06d4912255093419477d425633606e0e90782967" - dependencies: - jsbn "~0.1.0" - -js-base64@^2.1.9: - version "2.1.9" - resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.1.9.tgz#f0e80ae039a4bd654b5f281fc93f04a914a7fcce" - -js-tokens@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-2.0.0.tgz#79903f5563ee778cc1162e6dcf1a0027c97f9cb5" - -js-yaml@3.x, js-yaml@^3.5.1: - version "3.7.0" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.7.0.tgz#5c967ddd837a9bfdca5f2de84253abe8a1c03b80" - dependencies: - argparse "^1.0.7" - esprima "^2.6.0" - -js-yaml@~3.6.1: - version "3.6.1" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.6.1.tgz#6e5fe67d8b205ce4d22fad05b7781e8dadcc4b30" - dependencies: - argparse "^1.0.7" - esprima "^2.6.0" - -jsbn@~0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.0.tgz#650987da0dd74f4ebf5a11377a2aa2d273e97dfd" - -jsesc@~0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" - -json-loader@^0.5.4: - version "0.5.4" - resolved "https://registry.yarnpkg.com/json-loader/-/json-loader-0.5.4.tgz#8baa1365a632f58a3c46d20175fc6002c96e37de" - -json-schema@0.2.3: - version "0.2.3" - resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" - -json-stable-stringify@^1.0.0, json-stable-stringify@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz#9a759d39c5f2ff503fd5300646ed445f88c4f9af" - dependencies: - jsonify "~0.0.0" - -json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" - -json3@^3.3.2: - version "3.3.2" - resolved "https://registry.yarnpkg.com/json3/-/json3-3.3.2.tgz#3c0434743df93e2f5c42aee7b19bcb483575f4e1" - -json5@^0.5.0: - version "0.5.1" - resolved "https://registry.yarnpkg.com/json5/-/json5-0.5.1.tgz#1eade7acc012034ad84e2396767ead9fa5495821" - -jsonfile@^2.0.0, jsonfile@^2.1.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-2.4.0.tgz#3736a2b428b87bbda0cc83b53fa3d633a35c2ae8" - optionalDependencies: - graceful-fs "^4.1.6" - -jsonify@~0.0.0: - version "0.0.0" - resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73" - -jsonparse@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.2.0.tgz#5c0c5685107160e72fe7489bddea0b44c2bc67bd" - -jsonpointer@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-4.0.0.tgz#6661e161d2fc445f19f98430231343722e1fcbd5" - -jsprim@^1.2.2: - version "1.3.1" - resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.3.1.tgz#2a7256f70412a29ee3670aaca625994c4dcff252" - dependencies: - extsprintf "1.0.2" - json-schema "0.2.3" - verror "1.3.6" - -karma-sourcemap-loader@^0.3.7: - version "0.3.7" - resolved "https://registry.yarnpkg.com/karma-sourcemap-loader/-/karma-sourcemap-loader-0.3.7.tgz#91322c77f8f13d46fed062b042e1009d4c4505d8" - dependencies: - graceful-fs "^4.1.2" - -karma-webpack@^1.8.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/karma-webpack/-/karma-webpack-1.8.0.tgz#340c7999eb3745b47becab47d0d304dac2c55257" - dependencies: - async "~0.9.0" - loader-utils "^0.2.5" - lodash "^3.8.0" - source-map "^0.1.41" - webpack-dev-middleware "^1.0.11" - -kind-of@^3.0.2: - version "3.1.0" - resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.1.0.tgz#475d698a5e49ff5e53d14e3e732429dc8bf4cf47" - dependencies: - is-buffer "^1.0.2" - -klaw@^1.0.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/klaw/-/klaw-1.3.1.tgz#4088433b46b3b1ba259d78785d8e96f73ba02439" - optionalDependencies: - graceful-fs "^4.1.9" - -latest-version@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-2.0.0.tgz#56f8d6139620847b8017f8f1f4d78e211324168b" - dependencies: - package-json "^2.0.0" - -lazy-cache@^1.0.3: - version "1.0.4" - resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e" - -lazy-req@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/lazy-req/-/lazy-req-1.1.0.tgz#bdaebead30f8d824039ce0ce149d4daa07ba1fac" - -lcid@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/lcid/-/lcid-1.0.0.tgz#308accafa0bc483a3867b4b6f2b9506251d1b835" - dependencies: - invert-kv "^1.0.0" - -less-loader@^2.2.3: - version "2.2.3" - resolved "https://registry.yarnpkg.com/less-loader/-/less-loader-2.2.3.tgz#b6d8f8139c8493df09d992a93a00734b08f84528" - dependencies: - loader-utils "^0.2.5" - -less@^2.7.1: - version "2.7.1" - resolved "https://registry.yarnpkg.com/less/-/less-2.7.1.tgz#6cbfea22b3b830304e9a5fb371d54fa480c9d7cf" - optionalDependencies: - errno "^0.1.1" - graceful-fs "^4.1.2" - image-size "~0.5.0" - mime "^1.2.11" - mkdirp "^0.5.0" - promise "^7.1.1" - source-map "^0.5.3" - -levn@^0.3.0, levn@~0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" - dependencies: - prelude-ls "~1.1.2" - type-check "~0.3.2" - -linkify-it@~1.2.0: - version "1.2.4" - resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-1.2.4.tgz#0773526c317c8fd13bd534ee1d180ff88abf881a" - dependencies: - uc.micro "^1.0.1" - -load-json-file@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0" - dependencies: - graceful-fs "^4.1.2" - parse-json "^2.2.0" - pify "^2.0.0" - pinkie-promise "^2.0.0" - strip-bom "^2.0.0" - -loader-runner@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.2.0.tgz#824c1b699c4e7a2b6501b85902d5b862bf45b3fa" - -loader-utils@0.2.x, loader-utils@0.x.x, loader-utils@^0.2.11, loader-utils@^0.2.14, loader-utils@^0.2.15, loader-utils@^0.2.16, loader-utils@^0.2.3, loader-utils@^0.2.5, loader-utils@^0.2.7, loader-utils@^0.2.9, loader-utils@~0.2.2, loader-utils@~0.2.5: - version "0.2.16" - resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-0.2.16.tgz#f08632066ed8282835dff88dfb52704765adee6d" - dependencies: - big.js "^3.1.3" - emojis-list "^2.0.0" - json5 "^0.5.0" - object-assign "^4.0.1" - -lodash._arraycopy@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/lodash._arraycopy/-/lodash._arraycopy-3.0.0.tgz#76e7b7c1f1fb92547374878a562ed06a3e50f6e1" - -lodash._arrayeach@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/lodash._arrayeach/-/lodash._arrayeach-3.0.0.tgz#bab156b2a90d3f1bbd5c653403349e5e5933ef9e" - -lodash._baseassign@^3.0.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/lodash._baseassign/-/lodash._baseassign-3.2.0.tgz#8c38a099500f215ad09e59f1722fd0c52bfe0a4e" - dependencies: - lodash._basecopy "^3.0.0" - lodash.keys "^3.0.0" - -lodash._basecallback@^3.0.0: - version "3.3.1" - resolved "https://registry.yarnpkg.com/lodash._basecallback/-/lodash._basecallback-3.3.1.tgz#b7b2bb43dc2160424a21ccf26c57e443772a8e27" - dependencies: - lodash._baseisequal "^3.0.0" - lodash._bindcallback "^3.0.0" - lodash.isarray "^3.0.0" - lodash.pairs "^3.0.0" - -lodash._basecopy@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/lodash._basecopy/-/lodash._basecopy-3.0.1.tgz#8da0e6a876cf344c0ad8a54882111dd3c5c7ca36" - -lodash._baseeach@^3.0.0: - version "3.0.4" - resolved "https://registry.yarnpkg.com/lodash._baseeach/-/lodash._baseeach-3.0.4.tgz#cf8706572ca144e8d9d75227c990da982f932af3" - dependencies: - lodash.keys "^3.0.0" - -lodash._basefind@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/lodash._basefind/-/lodash._basefind-3.0.0.tgz#b2bba05cc645f972de2cf925fa2bf63a0f60c8ae" - -lodash._basefindindex@^3.0.0: - version "3.6.0" - resolved "https://registry.yarnpkg.com/lodash._basefindindex/-/lodash._basefindindex-3.6.0.tgz#f083360a1b022418ed81bc899beb312e21e74a4f" - -lodash._basefor@^3.0.0: - version "3.0.3" - resolved "https://registry.yarnpkg.com/lodash._basefor/-/lodash._basefor-3.0.3.tgz#7550b4e9218ef09fad24343b612021c79b4c20c2" - -lodash._baseisequal@^3.0.0: - version "3.0.7" - resolved "https://registry.yarnpkg.com/lodash._baseisequal/-/lodash._baseisequal-3.0.7.tgz#d8025f76339d29342767dcc887ce5cb95a5b51f1" - dependencies: - lodash.isarray "^3.0.0" - lodash.istypedarray "^3.0.0" - lodash.keys "^3.0.0" - -lodash._basetostring@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/lodash._basetostring/-/lodash._basetostring-3.0.1.tgz#d1861d877f824a52f669832dcaf3ee15566a07d5" - -lodash._basevalues@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/lodash._basevalues/-/lodash._basevalues-3.0.0.tgz#5b775762802bde3d3297503e26300820fdf661b7" - -lodash._bindcallback@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/lodash._bindcallback/-/lodash._bindcallback-3.0.1.tgz#e531c27644cf8b57a99e17ed95b35c748789392e" - -lodash._createassigner@^3.0.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/lodash._createassigner/-/lodash._createassigner-3.1.1.tgz#838a5bae2fdaca63ac22dee8e19fa4e6d6970b11" - dependencies: - lodash._bindcallback "^3.0.0" - lodash._isiterateecall "^3.0.0" - lodash.restparam "^3.0.0" - -lodash._createcompounder@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/lodash._createcompounder/-/lodash._createcompounder-3.0.0.tgz#5dd2cb55372d6e70e0e2392fb2304d6631091075" - dependencies: - lodash.deburr "^3.0.0" - lodash.words "^3.0.0" - -lodash._getnative@^3.0.0: - version "3.9.1" - resolved "https://registry.yarnpkg.com/lodash._getnative/-/lodash._getnative-3.9.1.tgz#570bc7dede46d61cdcde687d65d3eecbaa3aaff5" - -lodash._isiterateecall@^3.0.0: - version "3.0.9" - resolved "https://registry.yarnpkg.com/lodash._isiterateecall/-/lodash._isiterateecall-3.0.9.tgz#5203ad7ba425fae842460e696db9cf3e6aac057c" - -lodash._reescape@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/lodash._reescape/-/lodash._reescape-3.0.0.tgz#2b1d6f5dfe07c8a355753e5f27fac7f1cde1616a" - -lodash._reevaluate@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/lodash._reevaluate/-/lodash._reevaluate-3.0.0.tgz#58bc74c40664953ae0b124d806996daca431e2ed" - -lodash._reinterpolate@^3.0.0, lodash._reinterpolate@~3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d" - -lodash._root@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/lodash._root/-/lodash._root-3.0.1.tgz#fba1c4524c19ee9a5f8136b4609f017cf4ded692" - -lodash.assign@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/lodash.assign/-/lodash.assign-3.2.0.tgz#3ce9f0234b4b2223e296b8fa0ac1fee8ebca64fa" - dependencies: - lodash._baseassign "^3.0.0" - lodash._createassigner "^3.0.0" - lodash.keys "^3.0.0" - -lodash.assign@^4.0.3, lodash.assign@^4.0.6, lodash.assign@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/lodash.assign/-/lodash.assign-4.2.0.tgz#0d99f3ccd7a6d261d19bdaeb9245005d285808e7" - -lodash.camelcase@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-3.0.1.tgz#932c8b87f8a4377897c67197533282f97aeac298" - dependencies: - lodash._createcompounder "^3.0.0" - -lodash.clonedeep@^4.3.2: - version "4.5.0" - resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" - -lodash.deburr@^3.0.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/lodash.deburr/-/lodash.deburr-3.2.0.tgz#6da8f54334a366a7cf4c4c76ef8d80aa1b365ed5" - dependencies: - lodash._root "^3.0.0" - -lodash.escape@^3.0.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/lodash.escape/-/lodash.escape-3.2.0.tgz#995ee0dc18c1b48cc92effae71a10aab5b487698" - dependencies: - lodash._root "^3.0.0" - -lodash.find@^3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/lodash.find/-/lodash.find-3.2.1.tgz#046e319f3ace912ac6c9246c7f683c5ec07b36ad" - dependencies: - lodash._basecallback "^3.0.0" - lodash._baseeach "^3.0.0" - lodash._basefind "^3.0.0" - lodash._basefindindex "^3.0.0" - lodash.isarray "^3.0.0" - lodash.keys "^3.0.0" - -lodash.indexof@^4.0.5: - version "4.0.5" - resolved "https://registry.yarnpkg.com/lodash.indexof/-/lodash.indexof-4.0.5.tgz#53714adc2cddd6ed87638f893aa9b6c24e31ef3c" - -lodash.isarguments@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a" - -lodash.isarray@^3.0.0: - version "3.0.4" - resolved "https://registry.yarnpkg.com/lodash.isarray/-/lodash.isarray-3.0.4.tgz#79e4eb88c36a8122af86f844aa9bcd851b5fbb55" - -lodash.isequal@^4.2.0: - version "4.4.0" - resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.4.0.tgz#6295768e98e14dc15ce8d362ef6340db82852031" - -lodash.isplainobject@^3.0.0, lodash.isplainobject@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-3.2.0.tgz#9a8238ae16b200432960cd7346512d0123fbf4c5" - dependencies: - lodash._basefor "^3.0.0" - lodash.isarguments "^3.0.0" - lodash.keysin "^3.0.0" - -lodash.istypedarray@^3.0.0: - version "3.0.6" - resolved "https://registry.yarnpkg.com/lodash.istypedarray/-/lodash.istypedarray-3.0.6.tgz#c9a477498607501d8e8494d283b87c39281cef62" - -lodash.keys@^3.0.0: - version "3.1.2" - resolved "https://registry.yarnpkg.com/lodash.keys/-/lodash.keys-3.1.2.tgz#4dbc0472b156be50a0b286855d1bd0b0c656098a" - dependencies: - lodash._getnative "^3.0.0" - lodash.isarguments "^3.0.0" - lodash.isarray "^3.0.0" - -lodash.keysin@^3.0.0: - version "3.0.8" - resolved "https://registry.yarnpkg.com/lodash.keysin/-/lodash.keysin-3.0.8.tgz#22c4493ebbedb1427962a54b445b2c8a767fb47f" - dependencies: - lodash.isarguments "^3.0.0" - lodash.isarray "^3.0.0" - -lodash.merge@^3.0.2, lodash.merge@^3.3.2: - version "3.3.2" - resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-3.3.2.tgz#0d90d93ed637b1878437bb3e21601260d7afe994" - dependencies: - lodash._arraycopy "^3.0.0" - lodash._arrayeach "^3.0.0" - lodash._createassigner "^3.0.0" - lodash._getnative "^3.0.0" - lodash.isarguments "^3.0.0" - lodash.isarray "^3.0.0" - lodash.isplainobject "^3.0.0" - lodash.istypedarray "^3.0.0" - lodash.keys "^3.0.0" - lodash.keysin "^3.0.0" - lodash.toplainobject "^3.0.0" - -lodash.pairs@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/lodash.pairs/-/lodash.pairs-3.0.1.tgz#bbe08d5786eeeaa09a15c91ebf0dcb7d2be326a9" - dependencies: - lodash.keys "^3.0.0" - -lodash.restparam@^3.0.0: - version "3.6.1" - resolved "https://registry.yarnpkg.com/lodash.restparam/-/lodash.restparam-3.6.1.tgz#936a4e309ef330a7645ed4145986c85ae5b20805" - -lodash.template@^3.0.0: - version "3.6.2" - resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-3.6.2.tgz#f8cdecc6169a255be9098ae8b0c53d378931d14f" - dependencies: - lodash._basecopy "^3.0.0" - lodash._basetostring "^3.0.0" - lodash._basevalues "^3.0.0" - lodash._isiterateecall "^3.0.0" - lodash._reinterpolate "^3.0.0" - lodash.escape "^3.0.0" - lodash.keys "^3.0.0" - lodash.restparam "^3.0.0" - lodash.templatesettings "^3.0.0" - -lodash.template@^4.0.2: - version "4.4.0" - resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-4.4.0.tgz#e73a0385c8355591746e020b99679c690e68fba0" - dependencies: - lodash._reinterpolate "~3.0.0" - lodash.templatesettings "^4.0.0" - -lodash.templatesettings@^3.0.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/lodash.templatesettings/-/lodash.templatesettings-3.1.1.tgz#fb307844753b66b9f1afa54e262c745307dba8e5" - dependencies: - lodash._reinterpolate "^3.0.0" - lodash.escape "^3.0.0" - -lodash.templatesettings@^4.0.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/lodash.templatesettings/-/lodash.templatesettings-4.1.0.tgz#2b4d4e95ba440d915ff08bc899e4553666713316" - dependencies: - lodash._reinterpolate "~3.0.0" - -lodash.toplainobject@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/lodash.toplainobject/-/lodash.toplainobject-3.0.0.tgz#28790ad942d293d78aa663a07ecf7f52ca04198d" - dependencies: - lodash._basecopy "^3.0.0" - lodash.keysin "^3.0.0" - -lodash.words@^3.0.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/lodash.words/-/lodash.words-3.2.0.tgz#4e2a8649bc08745b17c695b1a3ce8fee596623b3" - dependencies: - lodash._root "^3.0.0" - -lodash@^3.10.0, lodash@^3.8.0: - version "3.10.1" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6" - -lodash@^4.0.0, lodash@^4.11.1, lodash@^4.14.0, lodash@^4.16.4, lodash@^4.17.2, lodash@^4.2.1, lodash@^4.3.0: - version "4.17.2" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.2.tgz#34a3055babe04ce42467b607d700072c7ff6bf42" - -lodash@~4.16.4: - version "4.16.6" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.16.6.tgz#d22c9ac660288f3843e16ba7d2b5d06cca27d777" - -lolex@1.3.2: - version "1.3.2" - resolved "https://registry.yarnpkg.com/lolex/-/lolex-1.3.2.tgz#7c3da62ffcb30f0f5a80a2566ca24e45d8a01f31" - -longest@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097" - -loud-rejection@^1.0.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/loud-rejection/-/loud-rejection-1.6.0.tgz#5b46f80147edee578870f086d04821cf998e551f" - dependencies: - currently-unhandled "^0.4.1" - signal-exit "^3.0.0" - -lower-case@^1.1.1: - version "1.1.3" - resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-1.1.3.tgz#c92393d976793eee5ba4edb583cf8eae35bd9bfb" - -lowercase-keys@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.0.tgz#4e3366b39e7f5457e35f1324bdf6f88d0bfc7306" - -lru-cache@2: - version "2.7.3" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-2.7.3.tgz#6d4524e8b955f95d4f5b58851ce21dd72fb4e952" - -lru-cache@^4.0.1: - version "4.0.2" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.0.2.tgz#1d17679c069cda5d040991a09dbc2c0db377e55e" - dependencies: - pseudomap "^1.0.1" - yallist "^2.0.0" - -macaddress@^0.2.8: - version "0.2.8" - resolved "https://registry.yarnpkg.com/macaddress/-/macaddress-0.2.8.tgz#5904dc537c39ec6dbefeae902327135fa8511f12" - -magic-string@^0.16.0: - version "0.16.0" - resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.16.0.tgz#970ebb0da7193301285fb1aa650f39bdd81eb45a" - dependencies: - vlq "^0.2.1" - -make-error@^1.1.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.2.1.tgz#9a6dfb4844423b9f145806728d05c6e935670e75" - -map-obj@^1.0.0, map-obj@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-1.0.1.tgz#d933ceb9205d82bdcf4886f6742bdc2b4dea146d" - -map-stream@~0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/map-stream/-/map-stream-0.1.0.tgz#e56aa94c4c8055a16404a0674b78f215f7c8e194" - -markdown-it-terminal@0.0.3: - version "0.0.3" - resolved "https://registry.yarnpkg.com/markdown-it-terminal/-/markdown-it-terminal-0.0.3.tgz#c77a8533c2170b46d2a907a3c3452d4d7f4aa5db" - dependencies: - ansi-styles "^2.1.0" - cardinal "^0.5.0" - cli-table "^0.3.1" - lodash.merge "^3.3.2" - markdown-it "^4.4.0" - -markdown-it@4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-4.3.0.tgz#0ee2b0724079d186b3f04b7345ce395ae47cc474" - dependencies: - argparse "~1.0.2" - entities "~1.1.1" - linkify-it "~1.2.0" - mdurl "~1.0.0" - uc.micro "^1.0.0" - -markdown-it@^4.4.0: - version "4.4.0" - resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-4.4.0.tgz#3df373dbea587a9a7fef3e56311b68908f75c414" - dependencies: - argparse "~1.0.2" - entities "~1.1.1" - linkify-it "~1.2.0" - mdurl "~1.0.0" - uc.micro "^1.0.0" - -matcher-collection@^1.0.0: - version "1.0.4" - resolved "https://registry.yarnpkg.com/matcher-collection/-/matcher-collection-1.0.4.tgz#2f66ae0869996f29e43d0b62c83dd1d43e581755" - dependencies: - minimatch "^3.0.2" - -math-expression-evaluator@^1.2.14: - version "1.2.14" - resolved "https://registry.yarnpkg.com/math-expression-evaluator/-/math-expression-evaluator-1.2.14.tgz#39511771ed9602405fba9affff17eb4d2a3843ab" - dependencies: - lodash.indexof "^4.0.5" - -md5-hex@^1.0.2: - version "1.3.0" - resolved "https://registry.yarnpkg.com/md5-hex/-/md5-hex-1.3.0.tgz#d2c4afe983c4370662179b8cad145219135046c4" - dependencies: - md5-o-matic "^0.1.1" - -md5-o-matic@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/md5-o-matic/-/md5-o-matic-0.1.1.tgz#822bccd65e117c514fab176b25945d54100a03c3" - -mdurl@~1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e" - -media-typer@0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" - -memory-fs@^0.3.0, memory-fs@~0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.3.0.tgz#7bcc6b629e3a43e871d7e29aca6ae8a7f15cbb20" - dependencies: - errno "^0.1.3" - readable-stream "^2.0.1" - -memory-fs@~0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.4.1.tgz#3a9a20b8462523e447cfbc7e8bb80ed667bfc552" - dependencies: - errno "^0.1.3" - readable-stream "^2.0.1" - -meow@^3.3.0, meow@^3.7.0: - version "3.7.0" - resolved "https://registry.yarnpkg.com/meow/-/meow-3.7.0.tgz#72cb668b425228290abbfa856892587308a801fb" - dependencies: - camelcase-keys "^2.0.0" - decamelize "^1.1.2" - loud-rejection "^1.0.0" - map-obj "^1.0.1" - minimist "^1.1.3" - normalize-package-data "^2.3.4" - object-assign "^4.0.1" - read-pkg-up "^1.0.1" - redent "^1.0.0" - trim-newlines "^1.0.0" - -merge-descriptors@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" - -methods@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" - -micromatch@^2.1.5, micromatch@^2.3.11: - version "2.3.11" - resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-2.3.11.tgz#86677c97d1720b363431d04d0d15293bd38c1565" - dependencies: - arr-diff "^2.0.0" - array-unique "^0.2.1" - braces "^1.8.2" - expand-brackets "^0.1.4" - extglob "^0.3.1" - filename-regex "^2.0.0" - is-extglob "^1.0.0" - is-glob "^2.0.1" - kind-of "^3.0.2" - normalize-path "^2.0.1" - object.omit "^2.0.0" - parse-glob "^3.0.4" - regex-cache "^0.4.2" - -miller-rabin@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/miller-rabin/-/miller-rabin-4.0.0.tgz#4a62fb1d42933c05583982f4c716f6fb9e6c6d3d" - dependencies: - bn.js "^4.0.0" - brorand "^1.0.1" - -"mime-db@>= 1.24.0 < 2", mime-db@~1.25.0: - version "1.25.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.25.0.tgz#c18dbd7c73a5dbf6f44a024dc0d165a1e7b1c392" - -mime-types@^2.1.12, mime-types@~2.1.11, mime-types@~2.1.13, mime-types@~2.1.7: - version "2.1.13" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.13.tgz#e07aaa9c6c6b9a7ca3012c69003ad25a39e92a88" - dependencies: - mime-db "~1.25.0" - -mime@1.2.x: - version "1.2.11" - resolved "https://registry.yarnpkg.com/mime/-/mime-1.2.11.tgz#58203eed86e3a5ef17aed2b7d9ebd47f0a60dd10" - -mime@1.3.4, mime@^1.2.11, mime@^1.3.4: - version "1.3.4" - resolved "https://registry.yarnpkg.com/mime/-/mime-1.3.4.tgz#115f9e3b6b3daf2959983cb38f149a2d40eb5d53" - -minimalistic-assert@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.0.tgz#702be2dda6b37f4836bcb3f5db56641b64a1d3d3" - -minimatch@0.3: - version "0.3.0" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-0.3.0.tgz#275d8edaac4f1bb3326472089e7949c8394699dd" - dependencies: - lru-cache "2" - sigmund "~1.0.0" - -"minimatch@2 || 3", minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.3, minimatch@~3.0.2: - version "3.0.3" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.3.tgz#2a4e4090b96b2db06a9d7df01055a62a77c9b774" - dependencies: - brace-expansion "^1.0.0" - -minimatch@2.x: - version "2.0.10" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-2.0.10.tgz#8d087c39c6b38c001b97fca7ce6d0e1e80afbac7" - dependencies: - brace-expansion "^1.0.0" - -minimist@0.0.8, minimist@~0.0.1: - version "0.0.8" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" - -minimist@^1.1.0, minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284" - -mkdirp@0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.3.0.tgz#1bbf5ab1ba827af23575143490426455f481fe1e" - -mkdirp@0.5.1, mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkdirp@~0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" - dependencies: - minimist "0.0.8" - -mktemp@~0.3.4: - version "0.3.5" - resolved "https://registry.yarnpkg.com/mktemp/-/mktemp-0.3.5.tgz#a1504c706d0d2b198c6a0eb645f7fdaf8181f7de" - -mocha@^2.4.5: - version "2.5.3" - resolved "https://registry.yarnpkg.com/mocha/-/mocha-2.5.3.tgz#161be5bdeb496771eb9b35745050b622b5aefc58" - dependencies: - commander "2.3.0" - debug "2.2.0" - diff "1.4.0" - escape-string-regexp "1.0.2" - glob "3.2.11" - growl "1.9.2" - jade "0.26.3" - mkdirp "0.5.1" - supports-color "1.2.0" - to-iso-string "0.0.2" - -mock-fs@3.10.0: - version "3.10.0" - resolved "https://registry.yarnpkg.com/mock-fs/-/mock-fs-3.10.0.tgz#a1b6635396d74332817371155db256947ccc2d34" - dependencies: - rewire "2.5.2" - semver "5.1.1" - -modify-values@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/modify-values/-/modify-values-1.0.0.tgz#e2b6cdeb9ce19f99317a53722f3dbf5df5eaaab2" - -ms@0.7.1: - version "0.7.1" - resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.1.tgz#9cd13c03adbff25b65effde7ce864ee952017098" - -ms@0.7.2: - version "0.7.2" - resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.2.tgz#ae25cf2512b3885a1d95d7f037868d8431124765" - -multipipe@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/multipipe/-/multipipe-0.1.2.tgz#2a8f2ddf70eed564dff2d57f1e1a137d9f05078b" - dependencies: - duplexer2 "0.0.2" - -mute-stream@0.0.4: - version "0.0.4" - resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.4.tgz#a9219960a6d5d5d046597aee51252c6655f7177e" - -mute-stream@0.0.5: - version "0.0.5" - resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.5.tgz#8fbfabb0a98a253d3184331f9e8deb7372fac6c0" - -nan@^2.3.0, nan@^2.3.2: - version "2.4.0" - resolved "https://registry.yarnpkg.com/nan/-/nan-2.4.0.tgz#fb3c59d45fe4effe215f0b890f8adf6eb32d2232" - -natives@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/natives/-/natives-1.1.0.tgz#e9ff841418a6b2ec7a495e939984f78f163e6e31" - -ncname@1.0.x: - version "1.0.0" - resolved "https://registry.yarnpkg.com/ncname/-/ncname-1.0.0.tgz#5b57ad18b1ca092864ef62b0b1ed8194f383b71c" - dependencies: - xml-char-classes "^1.0.0" - -negotiator@0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.1.tgz#2b327184e8992101177b28563fb5e7102acd0ca9" - -no-case@^2.2.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/no-case/-/no-case-2.3.0.tgz#ca2825ccb76b18e6f79d573dcfbf1eace33dd164" - dependencies: - lower-case "^1.1.1" - -node-gyp@^3.3.1: - version "3.4.0" - resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-3.4.0.tgz#dda558393b3ecbbe24c9e6b8703c71194c63fa36" - dependencies: - fstream "^1.0.0" - glob "^7.0.3" - graceful-fs "^4.1.2" - minimatch "^3.0.2" - mkdirp "^0.5.0" - nopt "2 || 3" - npmlog "0 || 1 || 2 || 3" - osenv "0" - path-array "^1.0.0" - request "2" - rimraf "2" - semver "2.x || 3.x || 4 || 5" - tar "^2.0.0" - which "1" - -node-libs-browser@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-1.1.1.tgz#2a38243abedd7dffcd07a97c9aca5668975a6fea" - dependencies: - assert "^1.1.1" - browserify-zlib "^0.1.4" - buffer "^4.3.0" - console-browserify "^1.1.0" - constants-browserify "^1.0.0" - crypto-browserify "^3.11.0" - domain-browser "^1.1.1" - events "^1.0.0" - https-browserify "0.0.1" - os-browserify "^0.2.0" - path-browserify "0.0.0" - process "^0.11.0" - punycode "^1.2.4" - querystring-es3 "^0.2.0" - readable-stream "^2.0.5" - stream-browserify "^2.0.1" - stream-http "^2.3.1" - string_decoder "^0.10.25" - timers-browserify "^1.4.2" - tty-browserify "0.0.0" - url "^0.11.0" - util "^0.10.3" - vm-browserify "0.0.4" - -node-modules-path@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/node-modules-path/-/node-modules-path-1.0.1.tgz#40096b08ce7ad0ea14680863af449c7c75a5d1c8" - -node-pre-gyp@^0.6.29: - version "0.6.32" - resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.6.32.tgz#fc452b376e7319b3d255f5f34853ef6fd8fe1fd5" - dependencies: - mkdirp "~0.5.1" - nopt "~3.0.6" - npmlog "^4.0.1" - rc "~1.1.6" - request "^2.79.0" - rimraf "~2.5.4" - semver "~5.3.0" - tar "~2.2.1" - tar-pack "~3.3.0" - -node-sass@^3.10.1: - version "3.13.1" - resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-3.13.1.tgz#7240fbbff2396304b4223527ed3020589c004fc2" - 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" - meow "^3.7.0" - mkdirp "^0.5.1" - nan "^2.3.2" - node-gyp "^3.3.1" - npmlog "^4.0.0" - request "^2.61.0" - sass-graph "^2.1.1" - -node-status-codes@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/node-status-codes/-/node-status-codes-1.0.0.tgz#5ae5541d024645d32a58fcddc9ceecea7ae3ac2f" - -"nopt@2 || 3", nopt@3.x, nopt@^3.0.1, nopt@~3.0.6: - version "3.0.6" - resolved "https://registry.yarnpkg.com/nopt/-/nopt-3.0.6.tgz#c6465dbf08abcd4db359317f79ac68a646b28ff9" - dependencies: - abbrev "1" - -normalize-package-data@^2.3.0, normalize-package-data@^2.3.2, normalize-package-data@^2.3.4, normalize-package-data@^2.3.5: - version "2.3.5" - resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.3.5.tgz#8d924f142960e1777e7ffe170543631cc7cb02df" - dependencies: - hosted-git-info "^2.1.4" - is-builtin-module "^1.0.0" - semver "2 || 3 || 4 || 5" - validate-npm-package-license "^3.0.1" - -normalize-path@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.0.1.tgz#47886ac1662760d4261b7d979d241709d3ce3f7a" - -normalize-range@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" - -normalize-url@^1.4.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-1.8.0.tgz#a9550b079aa3523c85d78df24eef1959fce359ab" - dependencies: - object-assign "^4.0.1" - prepend-http "^1.0.0" - query-string "^4.1.0" - sort-keys "^1.0.0" - -npm-path@^2.0.2: - version "2.0.3" - resolved "https://registry.yarnpkg.com/npm-path/-/npm-path-2.0.3.tgz#15cff4e1c89a38da77f56f6055b24f975dfb2bbe" - dependencies: - which "^1.2.10" - -npm-run-all@^3.0.0: - version "3.1.2" - resolved "https://registry.yarnpkg.com/npm-run-all/-/npm-run-all-3.1.2.tgz#c7e3faf4aa0a59bf0dcfc12601166151692171cf" - dependencies: - chalk "^1.1.3" - cross-spawn "^4.0.0" - minimatch "^3.0.2" - object-assign "^4.0.1" - pinkie-promise "^2.0.1" - ps-tree "^1.0.1" - read-pkg "^1.1.0" - read-pkg-up "^1.0.1" - shell-quote "^1.6.1" - string.prototype.padend "^3.0.0" - -npm-run@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/npm-run/-/npm-run-4.1.0.tgz#a14e0ec854d8e837cc871495eb3644dde8a2f46b" - dependencies: - cross-spawn "^4.0.0" - minimist "^1.2.0" - npm-path "^2.0.2" - npm-which "^3.0.1" - serializerr "^1.0.2" - sync-exec "^0.6.2" - -npm-which@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/npm-which/-/npm-which-3.0.1.tgz#9225f26ec3a285c209cae67c3b11a6b4ab7140aa" - dependencies: - commander "^2.9.0" - npm-path "^2.0.2" - which "^1.2.10" - -"npmlog@0 || 1 || 2 || 3": - version "3.1.2" - resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-3.1.2.tgz#2d46fa874337af9498a2f12bb43d8d0be4a36873" - dependencies: - are-we-there-yet "~1.1.2" - console-control-strings "~1.1.0" - gauge "~2.6.0" - set-blocking "~2.0.0" - -npmlog@^4.0.0, npmlog@^4.0.1: - version "4.0.2" - resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.0.2.tgz#d03950e0e78ce1527ba26d2a7592e9348ac3e75f" - dependencies: - are-we-there-yet "~1.1.2" - console-control-strings "~1.1.0" - gauge "~2.7.1" - set-blocking "~2.0.0" - -nth-check@~1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-1.0.1.tgz#9929acdf628fc2c41098deab82ac580cf149aae4" - dependencies: - boolbase "~1.0.0" - -num2fraction@^1.2.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/num2fraction/-/num2fraction-1.2.2.tgz#6f682b6a027a4e9ddfa4564cd2589d1d4e669ede" - -number-is-nan@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" - -oauth-sign@~0.8.1: - version "0.8.2" - resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.8.2.tgz#46a6ab7f0aead8deae9ec0565780b7d4efeb9d43" - -object-assign@4.x.x, object-assign@^4.0.1, object-assign@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.0.tgz#7a3b3d0e98063d43f4c03f2e8ae6cd51a86883a0" - -object-assign@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-3.0.0.tgz#9bedd5ca0897949bca47e7ff408062d549f587f2" - -object-keys@^1.0.8: - version "1.0.11" - resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.0.11.tgz#c54601778ad560f1142ce0e01bcca8b56d13426d" - -object.omit@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/object.omit/-/object.omit-2.0.1.tgz#1a9c744829f39dbb858c76ca3579ae2a54ebd1fa" - dependencies: - for-own "^0.1.4" - is-extendable "^0.1.1" - -obuf@^1.0.0, obuf@^1.1.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.1.tgz#104124b6c602c6796881a042541d36db43a5264e" - -offline-plugin@^3.4.1: - version "3.4.2" - resolved "https://registry.yarnpkg.com/offline-plugin/-/offline-plugin-3.4.2.tgz#ddbb9eec3e8fd052ec51e03c56afdc33b0a331a2" - dependencies: - deep-extend "^0.4.0" - ejs "^2.3.4" - es6-promise "^3.0.2" - loader-utils "0.2.x" - minimatch "^3.0.2" - -on-finished@~2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" - dependencies: - ee-first "1.1.1" - -on-headers@~1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.1.tgz#928f5d0f470d49342651ea6794b0857c100693f7" - -once@1.x, once@^1.3.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" - dependencies: - wrappy "1" - -once@~1.3.3: - version "1.3.3" - resolved "https://registry.yarnpkg.com/once/-/once-1.3.3.tgz#b2e261557ce4c314ec8304f3fa82663e4297ca20" - dependencies: - wrappy "1" - -onetime@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/onetime/-/onetime-1.1.0.tgz#a1f7838f8314c516f05ecefcbc4ccfe04b4ed789" - -opn@4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/opn/-/opn-4.0.1.tgz#9bd30ee3eba4fd533be2c83d56329a4e58913bf8" - dependencies: - object-assign "^4.0.1" - pinkie-promise "^2.0.0" - -opn@4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/opn/-/opn-4.0.2.tgz#7abc22e644dff63b0a96d5ab7f2790c0f01abc95" - dependencies: - object-assign "^4.0.1" - pinkie-promise "^2.0.0" - -optimist@^0.6.1, optimist@~0.6.0: - version "0.6.1" - resolved "https://registry.yarnpkg.com/optimist/-/optimist-0.6.1.tgz#da3ea74686fa21a19a111c326e90eb15a0196686" - dependencies: - minimist "~0.0.1" - wordwrap "~0.0.2" - -optionator@^0.8.1: - version "0.8.2" - resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.2.tgz#364c5e409d3f4d6301d6c0b4c05bba50180aeb64" - dependencies: - deep-is "~0.1.3" - fast-levenshtein "~2.0.4" - levn "~0.3.0" - prelude-ls "~1.1.2" - type-check "~0.3.2" - wordwrap "~1.0.0" - -ora@^0.2.0: - version "0.2.3" - resolved "https://registry.yarnpkg.com/ora/-/ora-0.2.3.tgz#37527d220adcd53c39b73571d754156d5db657a4" - dependencies: - chalk "^1.1.1" - cli-cursor "^1.0.2" - cli-spinners "^0.1.2" - object-assign "^4.0.1" - -original@>=0.0.5: - version "1.0.0" - resolved "https://registry.yarnpkg.com/original/-/original-1.0.0.tgz#9147f93fa1696d04be61e01bd50baeaca656bd3b" - dependencies: - url-parse "1.0.x" - -os-browserify@^0.2.0: - version "0.2.1" - resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.2.1.tgz#63fc4ccee5d2d7763d26bbf8601078e6c2e0044f" - -os-homedir@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" - -os-locale@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-1.4.0.tgz#20f9f17ae29ed345e8bde583b13d2009803c14d9" - dependencies: - lcid "^1.0.0" - -os-tmpdir@^1.0.0, os-tmpdir@~1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" - -osenv@0, osenv@^0.1.0: - version "0.1.4" - resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.4.tgz#42fe6d5953df06c8064be6f176c3d05aaaa34644" - dependencies: - os-homedir "^1.0.0" - os-tmpdir "^1.0.0" - -package-json@^2.0.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/package-json/-/package-json-2.4.0.tgz#0d15bd67d1cbbddbb2ca222ff2edb86bcb31a8bb" - dependencies: - got "^5.0.0" - registry-auth-token "^3.0.1" - registry-url "^3.0.3" - semver "^5.1.0" - -pako@~0.2.0: - version "0.2.9" - resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75" - -param-case@2.1.x: - version "2.1.0" - resolved "https://registry.yarnpkg.com/param-case/-/param-case-2.1.0.tgz#2619f90fd6c829ed0b958f1c84ed03a745a6d70a" - dependencies: - no-case "^2.2.0" - -parse-asn1@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/parse-asn1/-/parse-asn1-5.0.0.tgz#35060f6d5015d37628c770f4e091a0b5a278bc23" - 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.3.0" - resolved "https://registry.yarnpkg.com/parse-github-repo-url/-/parse-github-repo-url-1.3.0.tgz#d4de02d68e2e60f0d6a182e7a8cb21b6f38c730b" - -parse-glob@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/parse-glob/-/parse-glob-3.0.4.tgz#b2c376cfb11f35513badd173ef0bb6e3a388391c" - dependencies: - glob-base "^0.3.0" - is-dotfile "^1.0.0" - is-extglob "^1.0.0" - is-glob "^2.0.0" - -parse-json@^2.1.0, parse-json@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-2.2.0.tgz#f480f40434ef80741f8469099f8dea18f55a4dc9" - dependencies: - error-ex "^1.2.0" - -parseurl@~1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.1.tgz#c8ab8c9223ba34888aa64a297b28853bec18da56" - -path-array@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/path-array/-/path-array-1.0.1.tgz#7e2f0f35f07a2015122b868b7eac0eb2c4fec271" - dependencies: - array-index "^1.0.0" - -path-browserify@0.0.0: - version "0.0.0" - resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-0.0.0.tgz#a0b870729aae214005b7d5032ec2cbbb0fb4451a" - -path-exists@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-2.1.0.tgz#0feb6c64f0fc518d9a754dd5efb62c7022761f4b" - dependencies: - pinkie-promise "^2.0.0" - -path-is-absolute@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" - -path-is-inside@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53" - -path-posix@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/path-posix/-/path-posix-1.0.0.tgz#06b26113f56beab042545a23bfa88003ccac260f" - -path-to-regexp@0.1.7: - version "0.1.7" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" - -path-type@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441" - dependencies: - graceful-fs "^4.1.2" - pify "^2.0.0" - pinkie-promise "^2.0.0" - -pause-stream@0.0.11: - version "0.0.11" - resolved "https://registry.yarnpkg.com/pause-stream/-/pause-stream-0.0.11.tgz#fe5a34b0cbce12b5aa6a2b403ee2e73b602f1445" - dependencies: - through "~2.3" - -pbkdf2@^3.0.3: - version "3.0.9" - resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.0.9.tgz#f2c4b25a600058b3c3773c086c37dbbee1ffe693" - dependencies: - create-hmac "^1.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" - -pinkie-promise@^2.0.0, pinkie-promise@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa" - dependencies: - pinkie "^2.0.0" - -pinkie@^2.0.0, pinkie@^2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870" - -pluralize@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-1.2.1.tgz#d1a21483fd22bb41e58a12fa3421823140897c45" - -portfinder@1.0.9: - version "1.0.9" - resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.9.tgz#b1ac8755d092afc0433f1c4832fa17d6d1f5d830" - dependencies: - async "^1.5.2" - debug "^2.2.0" - mkdirp "0.5.x" - -postcss-calc@^5.2.0: - version "5.3.1" - resolved "https://registry.yarnpkg.com/postcss-calc/-/postcss-calc-5.3.1.tgz#77bae7ca928ad85716e2fda42f261bf7c1d65b5e" - dependencies: - postcss "^5.0.2" - postcss-message-helpers "^2.0.0" - reduce-css-calc "^1.2.6" - -postcss-colormin@^2.1.8: - version "2.2.1" - resolved "https://registry.yarnpkg.com/postcss-colormin/-/postcss-colormin-2.2.1.tgz#dc5421b6ae6f779ef6bfd47352b94abe59d0316b" - dependencies: - colormin "^1.0.5" - postcss "^5.0.13" - postcss-value-parser "^3.2.3" - -postcss-convert-values@^2.3.4: - version "2.5.0" - resolved "https://registry.yarnpkg.com/postcss-convert-values/-/postcss-convert-values-2.5.0.tgz#570aceb04b3061fb25f6f46bd0329e7ab6263c0b" - dependencies: - postcss "^5.0.11" - postcss-value-parser "^3.1.2" - -postcss-discard-comments@^2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/postcss-discard-comments/-/postcss-discard-comments-2.0.4.tgz#befe89fafd5b3dace5ccce51b76b81514be00e3d" - dependencies: - postcss "^5.0.14" - -postcss-discard-duplicates@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/postcss-discard-duplicates/-/postcss-discard-duplicates-2.0.2.tgz#02be520e91571ffb10738766a981d5770989bb32" - dependencies: - postcss "^5.0.4" - -postcss-discard-empty@^2.0.1: - version "2.1.0" - resolved "https://registry.yarnpkg.com/postcss-discard-empty/-/postcss-discard-empty-2.1.0.tgz#d2b4bd9d5ced5ebd8dcade7640c7d7cd7f4f92b5" - dependencies: - postcss "^5.0.14" - -postcss-discard-overridden@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/postcss-discard-overridden/-/postcss-discard-overridden-0.1.1.tgz#8b1eaf554f686fb288cd874c55667b0aa3668d58" - dependencies: - postcss "^5.0.16" - -postcss-discard-unused@^2.2.1: - version "2.2.3" - resolved "https://registry.yarnpkg.com/postcss-discard-unused/-/postcss-discard-unused-2.2.3.tgz#bce30b2cc591ffc634322b5fb3464b6d934f4433" - dependencies: - postcss "^5.0.14" - uniqs "^2.0.0" - -postcss-filter-plugins@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/postcss-filter-plugins/-/postcss-filter-plugins-2.0.2.tgz#6d85862534d735ac420e4a85806e1f5d4286d84c" - dependencies: - postcss "^5.0.4" - uniqid "^4.0.0" - -postcss-loader@^0.9.1: - version "0.9.1" - resolved "https://registry.yarnpkg.com/postcss-loader/-/postcss-loader-0.9.1.tgz#87a3e70f58e46d68a75badc6725d9ea4773fd1d7" - dependencies: - loader-utils "^0.2.14" - postcss "^5.0.19" - -postcss-merge-idents@^2.1.5: - version "2.1.7" - resolved "https://registry.yarnpkg.com/postcss-merge-idents/-/postcss-merge-idents-2.1.7.tgz#4c5530313c08e1d5b3bbf3d2bbc747e278eea270" - dependencies: - has "^1.0.1" - postcss "^5.0.10" - postcss-value-parser "^3.1.1" - -postcss-merge-longhand@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/postcss-merge-longhand/-/postcss-merge-longhand-2.0.1.tgz#ff59b5dec6d586ce2cea183138f55c5876fa9cdc" - dependencies: - postcss "^5.0.4" - -postcss-merge-rules@^2.0.3: - version "2.0.11" - resolved "https://registry.yarnpkg.com/postcss-merge-rules/-/postcss-merge-rules-2.0.11.tgz#c5d7c8de5056a7377aea0dff2fd83f92cafb9b8a" - dependencies: - postcss "^5.0.4" - vendors "^1.0.0" - -postcss-message-helpers@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/postcss-message-helpers/-/postcss-message-helpers-2.0.0.tgz#a4f2f4fab6e4fe002f0aed000478cdf52f9ba60e" - -postcss-minify-font-values@^1.0.2: - version "1.0.5" - resolved "https://registry.yarnpkg.com/postcss-minify-font-values/-/postcss-minify-font-values-1.0.5.tgz#4b58edb56641eba7c8474ab3526cafd7bbdecb69" - dependencies: - object-assign "^4.0.1" - postcss "^5.0.4" - postcss-value-parser "^3.0.2" - -postcss-minify-gradients@^1.0.1: - version "1.0.5" - resolved "https://registry.yarnpkg.com/postcss-minify-gradients/-/postcss-minify-gradients-1.0.5.tgz#5dbda11373703f83cfb4a3ea3881d8d75ff5e6e1" - dependencies: - postcss "^5.0.12" - postcss-value-parser "^3.3.0" - -postcss-minify-params@^1.0.4: - version "1.1.0" - resolved "https://registry.yarnpkg.com/postcss-minify-params/-/postcss-minify-params-1.1.0.tgz#b6093472b5872a6deda47aa3b0b5b8b973547c50" - dependencies: - alphanum-sort "^1.0.1" - postcss "^5.0.2" - postcss-value-parser "^3.0.2" - uniqs "^2.0.0" - -postcss-minify-selectors@^2.0.4: - version "2.0.7" - resolved "https://registry.yarnpkg.com/postcss-minify-selectors/-/postcss-minify-selectors-2.0.7.tgz#bfb9248fe14db33770f036572de6b4897c48d81c" - dependencies: - alphanum-sort "^1.0.2" - has "^1.0.1" - postcss "^5.0.14" - postcss-selector-parser "^2.0.0" - -postcss-modules-extract-imports@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-1.0.1.tgz#8fb3fef9a6dd0420d3f6d4353cf1ff73f2b2a341" - dependencies: - postcss "^5.0.4" - -postcss-modules-local-by-default@^1.0.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-1.1.1.tgz#29a10673fa37d19251265ca2ba3150d9040eb4ce" - dependencies: - css-selector-tokenizer "^0.6.0" - postcss "^5.0.4" - -postcss-modules-scope@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-1.0.2.tgz#ff977395e5e06202d7362290b88b1e8cd049de29" - dependencies: - css-selector-tokenizer "^0.6.0" - postcss "^5.0.4" - -postcss-modules-values@^1.1.0: - version "1.2.2" - resolved "https://registry.yarnpkg.com/postcss-modules-values/-/postcss-modules-values-1.2.2.tgz#f0e7d476fe1ed88c5e4c7f97533a3e772ad94ca1" - dependencies: - icss-replace-symbols "^1.0.2" - postcss "^5.0.14" - -postcss-normalize-charset@^1.1.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/postcss-normalize-charset/-/postcss-normalize-charset-1.1.1.tgz#ef9ee71212d7fe759c78ed162f61ed62b5cb93f1" - dependencies: - postcss "^5.0.5" - -postcss-normalize-url@^3.0.7: - version "3.0.7" - resolved "https://registry.yarnpkg.com/postcss-normalize-url/-/postcss-normalize-url-3.0.7.tgz#6bd90d0a4bc5a1df22c26ea65c53257dc3829f4e" - dependencies: - is-absolute-url "^2.0.0" - normalize-url "^1.4.0" - postcss "^5.0.14" - postcss-value-parser "^3.2.3" - -postcss-ordered-values@^2.1.0: - version "2.2.2" - resolved "https://registry.yarnpkg.com/postcss-ordered-values/-/postcss-ordered-values-2.2.2.tgz#be8b511741fab2dac8e614a2302e9d10267b0771" - dependencies: - postcss "^5.0.4" - postcss-value-parser "^3.0.1" - -postcss-reduce-idents@^2.2.2: - version "2.3.1" - resolved "https://registry.yarnpkg.com/postcss-reduce-idents/-/postcss-reduce-idents-2.3.1.tgz#024e8e219f52773313408573db9645ba62d2d2fe" - dependencies: - postcss "^5.0.4" - postcss-value-parser "^3.0.2" - -postcss-reduce-initial@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/postcss-reduce-initial/-/postcss-reduce-initial-1.0.0.tgz#8f739b938289ef2e48936d7101783e4741ca9bbb" - dependencies: - postcss "^5.0.4" - -postcss-reduce-transforms@^1.0.3: - version "1.0.4" - resolved "https://registry.yarnpkg.com/postcss-reduce-transforms/-/postcss-reduce-transforms-1.0.4.tgz#ff76f4d8212437b31c298a42d2e1444025771ae1" - dependencies: - has "^1.0.1" - postcss "^5.0.8" - postcss-value-parser "^3.0.1" - -postcss-selector-parser@^2.0.0: - version "2.2.2" - resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-2.2.2.tgz#3d70f5adda130da51c7c0c2fc023f56b1374fe08" - dependencies: - flatten "^1.0.2" - indexes-of "^1.0.1" - uniq "^1.0.1" - -postcss-svgo@^2.1.1: - version "2.1.6" - resolved "https://registry.yarnpkg.com/postcss-svgo/-/postcss-svgo-2.1.6.tgz#b6df18aa613b666e133f08adb5219c2684ac108d" - dependencies: - is-svg "^2.0.0" - postcss "^5.0.14" - postcss-value-parser "^3.2.3" - svgo "^0.7.0" - -postcss-unique-selectors@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/postcss-unique-selectors/-/postcss-unique-selectors-2.0.2.tgz#981d57d29ddcb33e7b1dfe1fd43b8649f933ca1d" - dependencies: - alphanum-sort "^1.0.1" - postcss "^5.0.4" - uniqs "^2.0.0" - -postcss-value-parser@^3.0.1, postcss-value-parser@^3.0.2, postcss-value-parser@^3.1.1, postcss-value-parser@^3.1.2, postcss-value-parser@^3.2.3, postcss-value-parser@^3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-3.3.0.tgz#87f38f9f18f774a4ab4c8a232f5c5ce8872a9d15" - -postcss-zindex@^2.0.1: - version "2.2.0" - resolved "https://registry.yarnpkg.com/postcss-zindex/-/postcss-zindex-2.2.0.tgz#d2109ddc055b91af67fc4cb3b025946639d2af22" - dependencies: - has "^1.0.1" - postcss "^5.0.4" - uniqs "^2.0.0" - -postcss@^5.0.10, postcss@^5.0.11, postcss@^5.0.12, postcss@^5.0.13, postcss@^5.0.14, postcss@^5.0.16, postcss@^5.0.19, postcss@^5.0.2, postcss@^5.0.4, postcss@^5.0.5, postcss@^5.0.6, postcss@^5.0.8, postcss@^5.2.6: - version "5.2.6" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-5.2.6.tgz#a252cd67cd52585035f17e9ad12b35137a7bdd9e" - dependencies: - chalk "^1.1.3" - js-base64 "^2.1.9" - source-map "^0.5.6" - supports-color "^3.1.2" - -prelude-ls@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" - -prepend-http@^1.0.0, prepend-http@^1.0.1: - version "1.0.4" - resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc" - -preserve@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b" - -pretty-error@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/pretty-error/-/pretty-error-2.0.2.tgz#a7db19cbb529ca9f0af3d3a2f77d5caf8e5dec23" - dependencies: - renderkid "~2.0.0" - utila "~0.4" - -process-nextick-args@~1.0.6: - version "1.0.7" - resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-1.0.7.tgz#150e20b756590ad3f91093f25a4f2ad8bff30ba3" - -process-relative-require@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/process-relative-require/-/process-relative-require-1.0.0.tgz#1590dfcf5b8f2983ba53e398446b68240b4cc68a" - dependencies: - node-modules-path "^1.0.0" - -process@^0.11.0, process@~0.11.0: - version "0.11.9" - resolved "https://registry.yarnpkg.com/process/-/process-0.11.9.tgz#7bd5ad21aa6253e7da8682264f1e11d11c0318c1" - -progress@^1.1.8: - version "1.1.8" - resolved "https://registry.yarnpkg.com/progress/-/progress-1.1.8.tgz#e260c78f6161cdd9b0e56cc3e0a85de17c7a57be" - -promise-map-series@^0.2.1: - version "0.2.3" - resolved "https://registry.yarnpkg.com/promise-map-series/-/promise-map-series-0.2.3.tgz#c2d377afc93253f6bd03dbb77755eb88ab20a847" - dependencies: - rsvp "^3.0.14" - -promise@^7.1.1: - version "7.1.1" - resolved "https://registry.yarnpkg.com/promise/-/promise-7.1.1.tgz#489654c692616b8aa55b0724fa809bb7db49c5bf" - dependencies: - asap "~2.0.3" - -protochain@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/protochain/-/protochain-1.0.5.tgz#991c407e99de264aadf8f81504b5e7faf7bfa260" - -proxy-addr@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-1.1.2.tgz#b4cc5f22610d9535824c123aef9d3cf73c40ba37" - dependencies: - forwarded "~0.1.0" - ipaddr.js "1.1.1" - -prr@~0.0.0: - version "0.0.0" - resolved "https://registry.yarnpkg.com/prr/-/prr-0.0.0.tgz#1a84b85908325501411853d0081ee3fa86e2926a" - -ps-tree@^1.0.1: - version "1.1.0" - resolved "https://registry.yarnpkg.com/ps-tree/-/ps-tree-1.1.0.tgz#b421b24140d6203f1ed3c76996b4427b08e8c014" - dependencies: - event-stream "~3.3.0" - -pseudomap@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" - -public-encrypt@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/public-encrypt/-/public-encrypt-4.0.0.tgz#39f699f3a46560dd5ebacbca693caf7c65c18cc6" - dependencies: - bn.js "^4.1.0" - browserify-rsa "^4.0.0" - create-hash "^1.1.0" - parse-asn1 "^5.0.0" - randombytes "^2.0.1" - -punycode@1.3.2: - version "1.3.2" - resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" - -punycode@^1.2.4, punycode@^1.4.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" - -q@^1.1.2, q@^1.4.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/q/-/q-1.4.1.tgz#55705bcd93c5f3673530c2c2cbc0c2b3addc286e" - -qs@6.2.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.2.0.tgz#3b7848c03c2dece69a9522b0fae8c4126d745f3b" - -qs@~6.3.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.3.0.tgz#f403b264f23bc01228c74131b407f18d5ea5d442" - -query-string@^4.1.0: - version "4.2.3" - resolved "https://registry.yarnpkg.com/query-string/-/query-string-4.2.3.tgz#9f27273d207a25a8ee4c7b8c74dcd45d556db822" - dependencies: - object-assign "^4.1.0" - strict-uri-encode "^1.0.0" - -querystring-es3@^0.2.0: - version "0.2.1" - resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" - -querystring@0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" - -querystringify@0.0.x: - version "0.0.4" - resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-0.0.4.tgz#0cf7f84f9463ff0ae51c4c4b142d95be37724d9c" - -quick-temp@0.1.5, quick-temp@^0.1.3: - version "0.1.5" - resolved "https://registry.yarnpkg.com/quick-temp/-/quick-temp-0.1.5.tgz#0d0d67f0fb6a589a0e142f90985f76cdbaf403f7" - dependencies: - mktemp "~0.3.4" - rimraf "~2.2.6" - underscore.string "~2.3.3" - -randomatic@^1.1.3: - version "1.1.6" - resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-1.1.6.tgz#110dcabff397e9dcff7c0789ccc0a49adf1ec5bb" - dependencies: - is-number "^2.0.2" - kind-of "^3.0.2" - -randombytes@^2.0.0, randombytes@^2.0.1: - version "2.0.3" - resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.0.3.tgz#674c99760901c3c4112771a31e521dc349cc09ec" - -range-parser@^1.0.3, range-parser@~1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.0.tgz#f49be6b487894ddc40dcc94a322f611092e00d5e" - -raw-loader@^0.5.1, raw-loader@~0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/raw-loader/-/raw-loader-0.5.1.tgz#0c3d0beaed8a01c966d9787bf778281252a979aa" - -rc@^1.0.1, rc@^1.1.6, rc@~1.1.6: - version "1.1.6" - resolved "https://registry.yarnpkg.com/rc/-/rc-1.1.6.tgz#43651b76b6ae53b5c802f1151fa3fc3b059969c9" - dependencies: - deep-extend "~0.4.0" - ini "~1.3.0" - minimist "^1.2.0" - strip-json-comments "~1.0.4" - -read-all-stream@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/read-all-stream/-/read-all-stream-3.1.0.tgz#35c3e177f2078ef789ee4bfafa4373074eaef4fa" - dependencies: - pinkie-promise "^2.0.0" - readable-stream "^2.0.0" - -read-pkg-up@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-1.0.1.tgz#9d63c13276c065918d57f002a57f40a1b643fb02" - dependencies: - find-up "^1.0.0" - read-pkg "^1.0.0" - -read-pkg@^1.0.0, read-pkg@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-1.1.0.tgz#f5ffaa5ecd29cb31c0474bca7d756b6bb29e3f28" - dependencies: - load-json-file "^1.0.0" - normalize-package-data "^2.3.2" - path-type "^1.0.0" - -readable-stream@1.0: - version "1.0.34" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.34.tgz#125820e34bc842d2f2aaafafe4c2916ee32c157c" - dependencies: - core-util-is "~1.0.0" - inherits "~2.0.1" - isarray "0.0.1" - string_decoder "~0.10.x" - -readable-stream@^2.0.0, "readable-stream@^2.0.0 || ^1.1.13", readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.1.0: - version "2.2.2" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.2.2.tgz#a9e6fec3c7dda85f8bb1b3ba7028604556fc825e" - dependencies: - buffer-shims "^1.0.0" - 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" - -readable-stream@~1.1.9: - version "1.1.14" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9" - dependencies: - core-util-is "~1.0.0" - inherits "~2.0.1" - isarray "0.0.1" - string_decoder "~0.10.x" - -readable-stream@~2.0.0: - version "2.0.6" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.0.6.tgz#8f90341e68a53ccc928788dacfcd11b36eb9b78e" - 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" - -readable-stream@~2.1.4: - version "2.1.5" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.1.5.tgz#66fa8b720e1438b364681f2ad1a63c618448c9d0" - dependencies: - buffer-shims "^1.0.0" - 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" - -readdirp@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.1.0.tgz#4ed0ad060df3073300c48440373f72d1cc642d78" - dependencies: - graceful-fs "^4.1.2" - minimatch "^3.0.2" - readable-stream "^2.0.2" - set-immediate-shim "^1.0.1" - -readline2@0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/readline2/-/readline2-0.1.1.tgz#99443ba6e83b830ef3051bfd7dc241a82728d568" - dependencies: - mute-stream "0.0.4" - strip-ansi "^2.0.1" - -readline2@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/readline2/-/readline2-1.0.1.tgz#41059608ffc154757b715d9989d199ffbf372e35" - dependencies: - code-point-at "^1.0.0" - is-fullwidth-code-point "^1.0.0" - mute-stream "0.0.5" - -redent@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/redent/-/redent-1.0.0.tgz#cf916ab1fd5f1f16dfb20822dd6ec7f730c2afde" - dependencies: - indent-string "^2.1.0" - strip-indent "^1.0.1" - -redeyed@~0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/redeyed/-/redeyed-0.5.0.tgz#7ab000e60ee3875ac115d29edb32c1403c6c25d1" - dependencies: - esprima-fb "~12001.1.0-dev-harmony-fb" - -reduce-css-calc@^1.2.6: - version "1.3.0" - resolved "https://registry.yarnpkg.com/reduce-css-calc/-/reduce-css-calc-1.3.0.tgz#747c914e049614a4c9cfbba629871ad1d2927716" - dependencies: - balanced-match "^0.4.2" - math-expression-evaluator "^1.2.14" - reduce-function-call "^1.0.1" - -reduce-function-call@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/reduce-function-call/-/reduce-function-call-1.0.2.tgz#5a200bf92e0e37751752fe45b0ab330fd4b6be99" - dependencies: - balanced-match "^0.4.2" - -reflect-metadata@^0.1.2, reflect-metadata@^0.1.8: - version "0.1.8" - resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.8.tgz#72426d570b60776e3688968bd5ab9537a15cecf6" - -regenerate@^1.2.1: - version "1.3.2" - resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.3.2.tgz#d1941c67bad437e1be76433add5b385f95b19260" - -regenerator-runtime@^0.10.0: - version "0.10.1" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.10.1.tgz#257f41961ce44558b18f7814af48c17559f9faeb" - -regex-cache@^0.4.2: - version "0.4.3" - resolved "https://registry.yarnpkg.com/regex-cache/-/regex-cache-0.4.3.tgz#9b1a6c35d4d0dfcef5711ae651e8e9d3d7114145" - dependencies: - is-equal-shallow "^0.1.3" - is-primitive "^2.0.0" - -regexpu-core@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-1.0.0.tgz#86a763f58ee4d7c2f6b102e4764050de7ed90c6b" - dependencies: - regenerate "^1.2.1" - regjsgen "^0.2.0" - regjsparser "^0.1.4" - -registry-auth-token@^3.0.1: - version "3.1.0" - resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-3.1.0.tgz#997c08256e0c7999837b90e944db39d8a790276b" - dependencies: - rc "^1.1.6" - -registry-url@^3.0.3: - version "3.1.0" - resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-3.1.0.tgz#3d4ef870f73dde1d77f0cf9a381432444e174942" - 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" - -regjsparser@^0.1.4: - version "0.1.5" - resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.1.5.tgz#7ee8f84dc6fa792d3fd0ae228d24bd949ead205c" - dependencies: - jsesc "~0.5.0" - -relateurl@0.2.x: - version "0.2.7" - resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9" - -remap-istanbul@^0.6.4: - version "0.6.4" - resolved "https://registry.yarnpkg.com/remap-istanbul/-/remap-istanbul-0.6.4.tgz#ac551eff1aa641504b4f318d0303dda61e3bb695" - dependencies: - amdefine "1.0.0" - gulp-util "3.0.7" - istanbul "0.4.3" - source-map ">=0.5.6" - through2 "2.0.1" - -renderkid@~2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/renderkid/-/renderkid-2.0.0.tgz#1859753e7a5adbf35443aba0d4e4579e78abee85" - dependencies: - css-select "^1.1.0" - dom-converter "~0.1" - htmlparser2 "~3.3.0" - strip-ansi "^3.0.0" - utila "~0.3" - -repeat-element@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.2.tgz#ef089a178d1483baae4d93eb98b4f9e4e11d990a" - -repeat-string@^1.5.2: - version "1.6.1" - resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" - -repeating@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/repeating/-/repeating-2.0.1.tgz#5214c53a926d3552707527fbab415dbc08d06dda" - dependencies: - is-finite "^1.0.0" - -replace-ext@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-0.0.1.tgz#29bbd92078a739f0bcce2b4ee41e837953522924" - -request@2, request@^2.27.0, request@^2.61.0, request@^2.74.0, request@^2.75.0, request@^2.79.0: - version "2.79.0" - resolved "https://registry.yarnpkg.com/request/-/request-2.79.0.tgz#4dfe5bf6be8b8cdc37fcf93e04b65577722710de" - dependencies: - aws-sign2 "~0.6.0" - aws4 "^1.2.1" - caseless "~0.11.0" - combined-stream "~1.0.5" - extend "~3.0.0" - forever-agent "~0.6.1" - form-data "~2.1.1" - har-validator "~2.0.6" - hawk "~3.1.3" - http-signature "~1.1.0" - is-typedarray "~1.0.0" - isstream "~0.1.2" - json-stringify-safe "~5.0.1" - mime-types "~2.1.7" - oauth-sign "~0.8.1" - qs "~6.3.0" - stringstream "~0.0.4" - tough-cookie "~2.3.0" - tunnel-agent "~0.4.1" - uuid "^3.0.0" - -require-directory@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" - -require-main-filename@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-1.0.1.tgz#97f717b69d48784f5f526a6c5aa8ffdda055a4d1" - -require-uncached@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/require-uncached/-/require-uncached-1.0.3.tgz#4e0d56d6c9662fd31e43011c4b95aa49955421d3" - dependencies: - caller-path "^0.1.0" - resolve-from "^1.0.0" - -requires-port@1.0.x, requires-port@1.x.x: - version "1.0.0" - resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" - -resolve-bin@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/resolve-bin/-/resolve-bin-0.4.0.tgz#47132249891101afb19991fe937cb0a5f072e5d9" - dependencies: - find-parent-dir "~0.3.0" - -resolve-from@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-1.0.1.tgz#26cbfe935d1aeeeabb29bc3fe5aeb01e93d44226" - -resolve@1.1.x: - version "1.1.7" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" - -resolve@^1.1.6, resolve@^1.1.7: - version "1.2.0" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.2.0.tgz#9589c3f2f6149d1417a40becc1663db6ec6bc26c" - -restore-cursor@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-1.0.1.tgz#34661f46886327fed2991479152252df92daa541" - dependencies: - exit-hook "^1.0.0" - onetime "^1.0.0" - -rewire@2.5.2, rewire@^2.5.1: - version "2.5.2" - resolved "https://registry.yarnpkg.com/rewire/-/rewire-2.5.2.tgz#6427de7b7feefa7d36401507eb64a5385bc58dc7" - -right-align@^0.1.1: - version "0.1.3" - resolved "https://registry.yarnpkg.com/right-align/-/right-align-0.1.3.tgz#61339b722fe6a3515689210d24e14c96148613ef" - dependencies: - align-text "^0.1.1" - -rimraf@2, rimraf@^2.2.8, rimraf@^2.3.4, rimraf@^2.4.3, rimraf@^2.4.4, rimraf@^2.5.3, rimraf@~2.5.1, rimraf@~2.5.4: - version "2.5.4" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.5.4.tgz#96800093cbf1a0c86bd95b4625467535c29dfa04" - dependencies: - glob "^7.0.5" - -rimraf@~2.2.6: - version "2.2.8" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.2.8.tgz#e439be2aaee327321952730f99a8929e4fc50582" - -ripemd160@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-1.0.1.tgz#93a4bbd4942bc574b69a8fa57c71de10ecca7d6e" - -rsvp@^3.0.14, rsvp@^3.0.17, rsvp@^3.0.18, rsvp@^3.0.21: - version "3.3.3" - resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-3.3.3.tgz#34633caaf8bc66ceff4be3c2e1dffd032538a813" - -rsvp@~3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-3.2.1.tgz#07cb4a5df25add9e826ebc67dcc9fd89db27d84a" - -run-async@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/run-async/-/run-async-0.1.0.tgz#c8ad4a5e110661e402a7d21b530e009f25f8e389" - dependencies: - once "^1.3.0" - -rx-lite@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-3.1.2.tgz#19ce502ca572665f3b647b10939f97fd1615f102" - -rxjs@5.0.0-beta.12: - version "5.0.0-beta.12" - resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-5.0.0-beta.12.tgz#cdfde2d8c4639d20ae7794bff8fddf32da7ad337" - dependencies: - symbol-observable "^1.0.1" - -rxjs@5.0.0-rc.4: - version "5.0.0-rc.4" - resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-5.0.0-rc.4.tgz#a4d08bc5d7f30d48ed7130e2995490c326a325c4" - dependencies: - symbol-observable "^1.0.1" - -samsam@1.1.2, samsam@~1.1: - version "1.1.2" - resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.1.2.tgz#bec11fdc83a9fda063401210e40176c3024d1567" - -sass-graph@^2.1.1: - version "2.1.2" - resolved "https://registry.yarnpkg.com/sass-graph/-/sass-graph-2.1.2.tgz#965104be23e8103cb7e5f710df65935b317da57b" - dependencies: - glob "^7.0.0" - lodash "^4.0.0" - yargs "^4.7.1" - -sass-loader@^4.0.1: - version "4.1.0" - resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-4.1.0.tgz#fd8604c5bf90001b173bb27540e8f2f5ed64602b" - dependencies: - async "^2.0.1" - loader-utils "^0.2.15" - object-assign "^4.1.0" - -sax@0.5.x: - version "0.5.8" - resolved "https://registry.yarnpkg.com/sax/-/sax-0.5.8.tgz#d472db228eb331c2506b0e8c15524adb939d12c1" - -sax@~1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.1.tgz#7b8e656190b228e81a66aea748480d828cd2d37a" - -script-loader@^0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/script-loader/-/script-loader-0.7.0.tgz#685dc7e7069e0dee7a92674f0ebc5b0f55baa5ec" - dependencies: - raw-loader "~0.5.1" - -select-hose@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" - -semver-diff@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-2.1.0.tgz#4bbb8437c8d37e4b0cf1a68fd726ec6d645d6d36" - dependencies: - semver "^5.0.3" - -"semver@2 || 3 || 4 || 5", "semver@2.x || 3.x || 4 || 5", semver@^5.0.1, semver@^5.0.3, semver@^5.1.0, semver@~5.3.0: - version "5.3.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f" - -semver@5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.1.1.tgz#a3292a373e6f3e0798da0b20641b9a9c5bc47e19" - -send@0.14.1: - version "0.14.1" - resolved "https://registry.yarnpkg.com/send/-/send-0.14.1.tgz#a954984325392f51532a7760760e459598c89f7a" - dependencies: - debug "~2.2.0" - depd "~1.1.0" - destroy "~1.0.4" - encodeurl "~1.0.1" - escape-html "~1.0.3" - etag "~1.7.0" - fresh "0.3.0" - http-errors "~1.5.0" - mime "1.3.4" - ms "0.7.1" - on-finished "~2.3.0" - range-parser "~1.2.0" - statuses "~1.3.0" - -serializerr@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/serializerr/-/serializerr-1.0.3.tgz#12d4c5aa1c3ffb8f6d1dc5f395aa9455569c3f91" - dependencies: - protochain "^1.0.5" - -serve-index@^1.7.2: - version "1.8.0" - resolved "https://registry.yarnpkg.com/serve-index/-/serve-index-1.8.0.tgz#7c5d96c13fb131101f93c1c5774f8516a1e78d3b" - dependencies: - accepts "~1.3.3" - batch "0.5.3" - debug "~2.2.0" - escape-html "~1.0.3" - http-errors "~1.5.0" - mime-types "~2.1.11" - parseurl "~1.3.1" - -serve-static@~1.11.1: - version "1.11.1" - resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.11.1.tgz#d6cce7693505f733c759de57befc1af76c0f0805" - dependencies: - encodeurl "~1.0.1" - escape-html "~1.0.3" - parseurl "~1.3.1" - send "0.14.1" - -set-blocking@^2.0.0, set-blocking@~2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" - -set-immediate-shim@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz#4b2b1b27eb808a9f8dcc481a58e5e56f599f3f61" - -setprototypeof@1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.0.2.tgz#81a552141ec104b88e89ce383103ad5c66564d08" - -sha.js@^2.3.6: - version "2.4.8" - resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.8.tgz#37068c2c476b6baf402d14a49c67f597921f634f" - dependencies: - inherits "^2.0.1" - -shell-quote@^1.6.1: - version "1.6.1" - resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.6.1.tgz#f4781949cce402697127430ea3b3c5476f481767" - dependencies: - array-filter "~0.0.0" - array-map "~0.0.0" - array-reduce "~0.0.0" - jsonify "~0.0.0" - -shelljs@^0.6.0: - version "0.6.1" - resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.6.1.tgz#ec6211bed1920442088fe0f70b2837232ed2c8a8" - -sigmund@~1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/sigmund/-/sigmund-1.0.1.tgz#3ff21f198cad2175f9f3b781853fd94d0d19b590" - -signal-exit@^3.0.0: - version "3.0.2" - resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" - -silent-error@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/silent-error/-/silent-error-1.0.1.tgz#71b7d503d1c6f94882b51b56be879b113cb4822c" - dependencies: - debug "^2.2.0" - -sinon@^1.17.3: - version "1.17.6" - resolved "https://registry.yarnpkg.com/sinon/-/sinon-1.17.6.tgz#a43116db59577c8296356afee13fafc2332e58e1" - dependencies: - formatio "1.1.1" - lolex "1.3.2" - samsam "1.1.2" - util ">=0.10.3 <1" - -slice-ansi@0.0.4: - version "0.0.4" - resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-0.0.4.tgz#edbf8903f66f7ce2f8eafd6ceed65e264c831b35" - -slide@^1.1.5: - version "1.1.6" - resolved "https://registry.yarnpkg.com/slide/-/slide-1.1.6.tgz#56eb027d65b4d2dce6cb2e2d32c4d4afc9e1d707" - -sntp@1.x.x: - version "1.0.9" - resolved "https://registry.yarnpkg.com/sntp/-/sntp-1.0.9.tgz#6541184cc90aeea6c6e7b35e2659082443c66198" - dependencies: - hoek "2.x.x" - -sockjs-client@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/sockjs-client/-/sockjs-client-1.1.1.tgz#284843e9a9784d7c474b1571b3240fca9dda4bb0" - dependencies: - debug "^2.2.0" - eventsource "~0.1.6" - faye-websocket "~0.11.0" - inherits "^2.0.1" - json3 "^3.3.2" - url-parse "^1.1.1" - -sockjs@0.3.18: - version "0.3.18" - resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.18.tgz#d9b289316ca7df77595ef299e075f0f937eb4207" - dependencies: - faye-websocket "^0.10.0" - uuid "^2.0.2" - -sort-keys@^1.0.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad" - dependencies: - is-plain-obj "^1.0.0" - -source-list-map@^0.1.4, source-list-map@~0.1.0: - version "0.1.7" - resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-0.1.7.tgz#d4b5ce2a46535c72c7e8527c71a77d250618172e" - -source-map-loader@^0.1.5: - version "0.1.5" - resolved "https://registry.yarnpkg.com/source-map-loader/-/source-map-loader-0.1.5.tgz#0ec6ca58aa0e61463a28673dd4c49e8ba67dc718" - dependencies: - async "^0.9.0" - loader-utils "~0.2.2" - source-map "~0.1.33" - -source-map-support@^0.3.1: - version "0.3.3" - resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.3.3.tgz#34900977d5ba3f07c7757ee72e73bb1a9b53754f" - dependencies: - source-map "0.1.32" - -source-map-support@^0.4.0, source-map-support@^0.4.2: - version "0.4.6" - resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.4.6.tgz#32552aa64b458392a85eab3b0b5ee61527167aeb" - dependencies: - source-map "^0.5.3" - -source-map@0.1.32: - version "0.1.32" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.1.32.tgz#c8b6c167797ba4740a8ea33252162ff08591b266" - dependencies: - amdefine ">=0.0.4" - -source-map@0.1.x, source-map@^0.1.41, source-map@~0.1.33: - version "0.1.43" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.1.43.tgz#c24bc146ca517c1471f5dacbe2571b2b7f9e3346" - dependencies: - amdefine ">=0.0.4" - -source-map@0.4.x, source-map@^0.4.2, source-map@^0.4.4: - version "0.4.4" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.4.4.tgz#eba4f5da9c0dc999de68032d8b4f76173652036b" - dependencies: - amdefine ">=0.0.4" - -source-map@>=0.5.6, source-map@^0.5.3, source-map@^0.5.6, source-map@~0.5.1, source-map@~0.5.3: - version "0.5.6" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.6.tgz#75ce38f52bf0733c5a7f0c118d81334a2bb5f412" - -source-map@~0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.2.0.tgz#dab73fbcfc2ba819b4de03bd6f6eaa48164b3f9d" - dependencies: - amdefine ">=0.0.4" - -sourcemap-istanbul-instrumenter-loader@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/sourcemap-istanbul-instrumenter-loader/-/sourcemap-istanbul-instrumenter-loader-0.2.0.tgz#8fd72e8abd16ded58918a7474db5bb3d0dbbcacd" - dependencies: - istanbul "0.x.x" - loader-utils "0.x.x" - object-assign "4.x.x" - -sparkles@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/sparkles/-/sparkles-1.0.0.tgz#1acbbfb592436d10bbe8f785b7cc6f82815012c3" - -spdx-correct@~1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-1.0.2.tgz#4b3073d933ff51f3912f03ac5519498a4150db40" - dependencies: - spdx-license-ids "^1.0.2" - -spdx-expression-parse@~1.0.0: - version "1.0.4" - resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-1.0.4.tgz#9bdf2f20e1f40ed447fbe273266191fced51626c" - -spdx-license-ids@^1.0.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-1.2.2.tgz#c9df7a3424594ade6bd11900d596696dc06bac57" - -spdy-transport@^2.0.15: - version "2.0.18" - resolved "https://registry.yarnpkg.com/spdy-transport/-/spdy-transport-2.0.18.tgz#43fc9c56be2cccc12bb3e2754aa971154e836ea6" - dependencies: - debug "^2.2.0" - hpack.js "^2.1.6" - obuf "^1.1.0" - readable-stream "^2.0.1" - wbuf "^1.4.0" - -spdy@^3.4.1: - version "3.4.4" - resolved "https://registry.yarnpkg.com/spdy/-/spdy-3.4.4.tgz#e0406407ca90ff01b553eb013505442649f5a819" - dependencies: - debug "^2.2.0" - handle-thing "^1.2.4" - http-deceiver "^1.2.4" - select-hose "^2.0.0" - spdy-transport "^2.0.15" - -split2@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/split2/-/split2-2.1.0.tgz#7382c148cb622c4b28af7c727f9673730b73f474" - dependencies: - through2 "~2.0.0" - -split@0.3: - version "0.3.3" - resolved "https://registry.yarnpkg.com/split/-/split-0.3.3.tgz#cd0eea5e63a211dfff7eb0f091c4133e2d0dd28f" - dependencies: - through "2" - -split@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/split/-/split-1.0.0.tgz#c4395ce683abcd254bc28fe1dabb6e5c27dcffae" - dependencies: - through "2" - -sprintf-js@^1.0.3, sprintf-js@~1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" - -sshpk@^1.7.0: - version "1.10.1" - resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.10.1.tgz#30e1a5d329244974a1af61511339d595af6638b0" - dependencies: - asn1 "~0.2.3" - assert-plus "^1.0.0" - dashdash "^1.12.0" - getpass "^0.1.1" - optionalDependencies: - bcrypt-pbkdf "^1.0.0" - ecc-jsbn "~0.1.1" - jodid25519 "^1.0.0" - jsbn "~0.1.0" - tweetnacl "~0.14.0" - -"statuses@>= 1.3.1 < 2", statuses@~1.3.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.3.1.tgz#faf51b9eb74aaef3b3acf4ad5f61abf24cb7b93e" - -stream-browserify@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.1.tgz#66266ee5f9bdb9940a4e4514cafb43bb71e5c9db" - dependencies: - inherits "~2.0.1" - readable-stream "^2.0.2" - -stream-combiner@~0.0.4: - version "0.0.4" - resolved "https://registry.yarnpkg.com/stream-combiner/-/stream-combiner-0.0.4.tgz#4d5e433c185261dde623ca3f44c586bcf5c4ad14" - dependencies: - duplexer "~0.1.1" - -stream-http@^2.3.1: - version "2.5.0" - resolved "https://registry.yarnpkg.com/stream-http/-/stream-http-2.5.0.tgz#585eee513217ed98fe199817e7313b6f772a6802" - dependencies: - builtin-status-codes "^2.0.0" - inherits "^2.0.1" - readable-stream "^2.1.0" - to-arraybuffer "^1.0.0" - xtend "^4.0.0" - -strict-uri-encode@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" - -string-width@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" - dependencies: - code-point-at "^1.0.0" - is-fullwidth-code-point "^1.0.0" - strip-ansi "^3.0.0" - -string-width@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.0.0.tgz#635c5436cc72a6e0c387ceca278d4e2eec52687e" - dependencies: - is-fullwidth-code-point "^2.0.0" - strip-ansi "^3.0.0" - -string.prototype.padend@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/string.prototype.padend/-/string.prototype.padend-3.0.0.tgz#f3aaef7c1719f170c5eab1c32bf780d96e21f2f0" - dependencies: - define-properties "^1.1.2" - es-abstract "^1.4.3" - function-bind "^1.0.2" - -string_decoder@^0.10.25, string_decoder@~0.10.x: - version "0.10.31" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" - -stringstream@~0.0.4: - version "0.0.5" - resolved "https://registry.yarnpkg.com/stringstream/-/stringstream-0.0.5.tgz#4e484cd4de5a0bbbee18e46307710a8a81621878" - -strip-ansi@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-2.0.1.tgz#df62c1aa94ed2f114e1d0f21fd1d50482b79a60e" - dependencies: - ansi-regex "^1.0.0" - -strip-ansi@^3.0.0, strip-ansi@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" - dependencies: - ansi-regex "^2.0.0" - -strip-bom@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-2.0.0.tgz#6219a85616520491f35788bdbf1447a99c7e6b0e" - dependencies: - is-utf8 "^0.2.0" - -strip-indent@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-1.0.1.tgz#0c7962a6adefa7bbd4ac366460a638552ae1a0a2" - dependencies: - get-stdin "^4.0.1" - -strip-json-comments@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" - -strip-json-comments@~1.0.1, strip-json-comments@~1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-1.0.4.tgz#1e15fbcac97d3ee99bf2d73b4c656b082bbafb91" - -style-loader@^0.13.1: - version "0.13.1" - resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-0.13.1.tgz#468280efbc0473023cd3a6cd56e33b5a1d7fc3a9" - dependencies: - loader-utils "^0.2.7" - -stylus-loader@^2.1.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/stylus-loader/-/stylus-loader-2.4.0.tgz#2ffbf2854a2071086b88bc338f423ad985b43544" - dependencies: - loader-utils "^0.2.9" - when "~3.6.x" - -stylus@^0.54.5: - version "0.54.5" - resolved "https://registry.yarnpkg.com/stylus/-/stylus-0.54.5.tgz#42b9560931ca7090ce8515a798ba9e6aa3d6dc79" - dependencies: - css-parse "1.7.x" - debug "*" - glob "7.0.x" - mkdirp "0.5.x" - sax "0.5.x" - source-map "0.1.x" - -sum-up@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/sum-up/-/sum-up-1.0.3.tgz#1c661f667057f63bcb7875aa1438bc162525156e" - dependencies: - chalk "^1.0.0" - -supports-color@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-1.2.0.tgz#ff1ed1e61169d06b3cf2d588e188b18d8847e17e" - -supports-color@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-0.2.0.tgz#d92de2694eb3f67323973d7ae3d8b55b4c22190a" - -supports-color@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" - -supports-color@^3.1.0, supports-color@^3.1.1, supports-color@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-3.1.2.tgz#72a262894d9d408b956ca05ff37b2ed8a6e2a2d5" - dependencies: - has-flag "^1.0.0" - -svgo@^0.7.0: - version "0.7.1" - resolved "https://registry.yarnpkg.com/svgo/-/svgo-0.7.1.tgz#287320fed972cb097e72c2bb1685f96fe08f8034" - dependencies: - coa "~1.0.1" - colors "~1.1.2" - csso "~2.2.1" - js-yaml "~3.6.1" - mkdirp "~0.5.1" - sax "~1.2.1" - whet.extend "~0.9.9" - -symbol-observable@^1.0.1: - version "1.0.4" - resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.0.4.tgz#29bf615d4aa7121bdd898b22d4b3f9bc4e2aa03d" - -symlink-or-copy@^1.0.0, symlink-or-copy@^1.0.1, symlink-or-copy@^1.1.8: - version "1.1.8" - resolved "https://registry.yarnpkg.com/symlink-or-copy/-/symlink-or-copy-1.1.8.tgz#cabe61e0010c1c023c173b25ee5108b37f4b4aa3" - -sync-exec@^0.6.2: - version "0.6.2" - resolved "https://registry.yarnpkg.com/sync-exec/-/sync-exec-0.6.2.tgz#717d22cc53f0ce1def5594362f3a89a2ebb91105" - -table@^3.7.8: - version "3.8.3" - resolved "https://registry.yarnpkg.com/table/-/table-3.8.3.tgz#2bbc542f0fda9861a755d3947fefd8b3f513855f" - dependencies: - ajv "^4.7.0" - ajv-keywords "^1.0.0" - chalk "^1.1.1" - lodash "^4.0.0" - slice-ansi "0.0.4" - string-width "^2.0.0" - -tapable@^0.2.3, tapable@~0.2.3: - version "0.2.5" - resolved "https://registry.yarnpkg.com/tapable/-/tapable-0.2.5.tgz#1ff6ce7ade58e734ca9bfe36ba342304b377a4d0" - -tar-pack@~3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/tar-pack/-/tar-pack-3.3.0.tgz#30931816418f55afc4d21775afdd6720cee45dae" - dependencies: - debug "~2.2.0" - fstream "~1.0.10" - fstream-ignore "~1.0.5" - once "~1.3.3" - readable-stream "~2.1.4" - rimraf "~2.5.1" - tar "~2.2.1" - uid-number "~0.0.6" - -tar@^2.0.0, tar@~2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/tar/-/tar-2.2.1.tgz#8e4d2a256c0e2185c6b18ad694aec968b83cb1d1" - dependencies: - block-stream "*" - fstream "^1.0.2" - inherits "2" - -temp@0.8.3: - version "0.8.3" - resolved "https://registry.yarnpkg.com/temp/-/temp-0.8.3.tgz#e0c6bc4d26b903124410e4fed81103014dfc1f59" - dependencies: - os-tmpdir "^1.0.0" - rimraf "~2.2.6" - -text-extensions@^1.0.0: - version "1.3.3" - resolved "https://registry.yarnpkg.com/text-extensions/-/text-extensions-1.3.3.tgz#fef0c8ce07f5bb3b8297bcf075304531754124bf" - -text-table@~0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" - -"textextensions@1 || 2": - version "2.0.1" - resolved "https://registry.yarnpkg.com/textextensions/-/textextensions-2.0.1.tgz#be8cf22d65379c151319f88f0335ad8f667abdca" - -through2@2.0.1, through2@^2.0.0, through2@~2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.1.tgz#384e75314d49f32de12eebb8136b8eb6b5d59da9" - dependencies: - readable-stream "~2.0.0" - xtend "~4.0.0" - -through@2, "through@>=2.2.7 <3", through@^2.3.6, through@~2.3, through@~2.3.1: - version "2.3.8" - resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" - -time-stamp@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/time-stamp/-/time-stamp-1.0.1.tgz#9f4bd23559c9365966f3302dbba2b07c6b99b151" - -timed-out@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-3.1.0.tgz#43b98b14bb712c9161c28f4dc1f3068d67a04ec2" - -timers-browserify@^1.4.2: - version "1.4.2" - resolved "https://registry.yarnpkg.com/timers-browserify/-/timers-browserify-1.4.2.tgz#c9c58b575be8407375cb5e2462dacee74359f41d" - dependencies: - process "~0.11.0" - -tmp@0.0.28: - version "0.0.28" - resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.28.tgz#172735b7f614ea7af39664fa84cf0de4e515d120" - dependencies: - os-tmpdir "~1.0.1" - -to-arraybuffer@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz#7d229b1fcc637e466ca081180836a7aabff83f43" - -to-iso-string@0.0.2: - version "0.0.2" - resolved "https://registry.yarnpkg.com/to-iso-string/-/to-iso-string-0.0.2.tgz#4dc19e664dfccbe25bd8db508b00c6da158255d1" - -toposort@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/toposort/-/toposort-1.0.0.tgz#b66cf385a1a8a8e68e45b8259e7f55875e8b06ef" - -tough-cookie@~2.3.0: - version "2.3.2" - resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.3.2.tgz#f081f76e4c85720e6c37a5faced737150d84072a" - dependencies: - punycode "^1.4.1" - -tree-kill@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.1.0.tgz#c963dcf03722892ec59cba569e940b71954d1729" - -trim-newlines@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613" - -trim-off-newlines@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/trim-off-newlines/-/trim-off-newlines-1.0.1.tgz#9f9ba9d9efa8764c387698bcbfeb2c848f11adb3" - -tryit@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/tryit/-/tryit-1.0.3.tgz#393be730a9446fd1ead6da59a014308f36c289cb" - -ts-node@^1.3.0: - version "1.7.2" - resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-1.7.2.tgz#d67bbc5c48fde16c244debbfe81b020587369a02" - dependencies: - arrify "^1.0.0" - chalk "^1.1.1" - diff "^3.1.0" - make-error "^1.1.1" - minimist "^1.2.0" - mkdirp "^0.5.1" - pinkie "^2.0.4" - source-map-support "^0.4.0" - tsconfig "^5.0.2" - v8flags "^2.0.11" - xtend "^4.0.0" - yn "^1.2.0" - -tsconfig@^5.0.2: - version "5.0.3" - resolved "https://registry.yarnpkg.com/tsconfig/-/tsconfig-5.0.3.tgz#5f4278e701800967a8fc383fd19648878f2a6e3a" - dependencies: - any-promise "^1.3.0" - parse-json "^2.2.0" - strip-bom "^2.0.0" - strip-json-comments "^2.0.0" - -tsickle@^0.1.7: - version "0.1.7" - resolved "https://registry.yarnpkg.com/tsickle/-/tsickle-0.1.7.tgz#bfe8f4cdcdaf9a40b84a729a38480c2f824f18ab" - dependencies: - minimist "^1.2.0" - mkdirp "^0.5.1" - source-map "^0.4.2" - source-map-support "^0.3.1" - -tsickle@^0.2: - version "0.2.3" - resolved "https://registry.yarnpkg.com/tsickle/-/tsickle-0.2.3.tgz#3e7d3ff74bbecdadc011cfd747f991dcfd6503af" - dependencies: - minimist "^1.2.0" - mkdirp "^0.5.1" - source-map "^0.5.6" - source-map-support "^0.4.2" - -tslint-loader@^3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/tslint-loader/-/tslint-loader-3.3.0.tgz#6e6a50fc82e85ff3d1cb53e23dd24ae9e47d91ea" - dependencies: - loader-utils "^0.2.7" - mkdirp "^0.5.1" - object-assign "^4.0.1" - rimraf "^2.4.4" - -tslint@^4.0.2: - version "4.1.1" - resolved "https://registry.yarnpkg.com/tslint/-/tslint-4.1.1.tgz#ae15c9478d92eb2f01d5102c69c493ec02d7e7e4" - dependencies: - babel-code-frame "^6.20.0" - colors "^1.1.2" - diff "^3.0.1" - findup-sync "~0.3.0" - glob "^7.1.1" - optimist "~0.6.0" - resolve "^1.1.7" - underscore.string "^3.3.4" - update-notifier "^1.0.2" - -tty-browserify@0.0.0: - version "0.0.0" - resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6" - -tunnel-agent@~0.4.1: - version "0.4.3" - resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.4.3.tgz#6373db76909fe570e08d73583365ed828a74eeeb" - -tweetnacl@^0.14.3, tweetnacl@~0.14.0: - version "0.14.5" - resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" - -type-check@~0.3.2: - version "0.3.2" - resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72" - dependencies: - prelude-ls "~1.1.2" - -type-detect@0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-0.1.1.tgz#0ba5ec2a885640e470ea4e8505971900dac58822" - -type-detect@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-1.0.0.tgz#762217cc06db258ec48908a1298e8b95121e8ea2" - -type-is@~1.6.13: - version "1.6.14" - resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.14.tgz#e219639c17ded1ca0789092dd54a03826b817cb2" - dependencies: - media-typer "0.3.0" - mime-types "~2.1.13" - -typedarray@~0.0.5: - version "0.0.6" - resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" - -typescript@~2.0.3: - version "2.0.10" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-2.0.10.tgz#ccdd4ed86fd5550a407101a0814012e1b3fac3dd" - -uc.micro@^1.0.0, uc.micro@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.3.tgz#7ed50d5e0f9a9fb0a573379259f2a77458d50192" - -uglify-js@2.7.x, uglify-js@^2.6, uglify-js@~2.7.3: - version "2.7.5" - resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.7.5.tgz#4612c0c7baaee2ba7c487de4904ae122079f2ca8" - dependencies: - async "~0.2.6" - source-map "~0.5.1" - uglify-to-browserify "~1.0.0" - yargs "~3.10.0" - -uglify-to-browserify@~1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz#6e0924d6bda6b5afe349e39a6d632850a0f882b7" - -uid-number@~0.0.6: - version "0.0.6" - resolved "https://registry.yarnpkg.com/uid-number/-/uid-number-0.0.6.tgz#0ea10e8035e8eb5b8e4449f06da1c730663baa81" - -underscore.string@^3.3.4: - version "3.3.4" - resolved "https://registry.yarnpkg.com/underscore.string/-/underscore.string-3.3.4.tgz#2c2a3f9f83e64762fdc45e6ceac65142864213db" - dependencies: - sprintf-js "^1.0.3" - util-deprecate "^1.0.2" - -underscore.string@~2.3.3: - version "2.3.3" - resolved "https://registry.yarnpkg.com/underscore.string/-/underscore.string-2.3.3.tgz#71c08bf6b428b1133f37e78fa3a21c82f7329b0d" - -uniq@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/uniq/-/uniq-1.0.1.tgz#b31c5ae8254844a3a8281541ce2b04b865a734ff" - -uniqid@^4.0.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/uniqid/-/uniqid-4.1.0.tgz#33d9679f65022f48988a03fd24e7dcaf8f109eca" - dependencies: - macaddress "^0.2.8" - -uniqs@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/uniqs/-/uniqs-2.0.0.tgz#ffede4b36b25290696e6e165d4a59edb998e6b02" - -unpipe@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" - -unzip-response@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-1.0.2.tgz#b984f0877fc0a89c2c773cc1ef7b5b232b5b06fe" - -update-notifier@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-1.0.3.tgz#8f92c515482bd6831b7c93013e70f87552c7cf5a" - dependencies: - boxen "^0.6.0" - chalk "^1.0.0" - configstore "^2.0.0" - is-npm "^1.0.0" - latest-version "^2.0.0" - lazy-req "^1.1.0" - semver-diff "^2.0.0" - xdg-basedir "^2.0.0" - -upper-case@^1.1.1: - version "1.1.3" - resolved "https://registry.yarnpkg.com/upper-case/-/upper-case-1.1.3.tgz#f6b4501c2ec4cdd26ba78be7222961de77621598" - -url-loader@^0.5.7: - version "0.5.7" - resolved "https://registry.yarnpkg.com/url-loader/-/url-loader-0.5.7.tgz#67e8779759f8000da74994906680c943a9b0925d" - dependencies: - loader-utils "0.2.x" - mime "1.2.x" - -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" - dependencies: - prepend-http "^1.0.1" - -url-parse@1.0.x: - version "1.0.5" - resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.0.5.tgz#0854860422afdcfefeb6c965c662d4800169927b" - dependencies: - querystringify "0.0.x" - requires-port "1.0.x" - -url-parse@^1.1.1: - version "1.1.7" - resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.1.7.tgz#025cff999653a459ab34232147d89514cc87d74a" - dependencies: - querystringify "0.0.x" - requires-port "1.0.x" - -url@^0.11.0: - version "0.11.0" - resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1" - dependencies: - punycode "1.3.2" - querystring "0.2.0" - -user-home@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/user-home/-/user-home-1.1.1.tgz#2b5be23a32b63a7c9deb8d0f28d485724a3df190" - -user-home@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/user-home/-/user-home-2.0.0.tgz#9c70bfd8169bc1dcbf48604e0f04b8b49cde9e9f" - dependencies: - os-homedir "^1.0.0" - -util-deprecate@^1.0.2, util-deprecate@~1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" - -util@0.10.3, "util@>=0.10.3 <1", util@^0.10.3: - version "0.10.3" - resolved "https://registry.yarnpkg.com/util/-/util-0.10.3.tgz#7afb1afe50805246489e3db7fe0ed379336ac0f9" - dependencies: - inherits "2.0.1" - -utila@~0.3: - version "0.3.3" - resolved "https://registry.yarnpkg.com/utila/-/utila-0.3.3.tgz#d7e8e7d7e309107092b05f8d9688824d633a4226" - -utila@~0.4: - version "0.4.0" - resolved "https://registry.yarnpkg.com/utila/-/utila-0.4.0.tgz#8a16a05d445657a3aea5eecc5b12a4fa5379772c" - -utils-merge@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.0.tgz#0294fb922bb9375153541c4f7096231f287c8af8" - -uuid@^2.0.1, uuid@^2.0.2: - version "2.0.3" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-2.0.3.tgz#67e2e863797215530dff318e5bf9dcebfd47b21a" - -uuid@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.0.1.tgz#6544bba2dfda8c1cf17e629a3a305e2bb1fee6c1" - -v8flags@^2.0.11: - version "2.0.11" - resolved "https://registry.yarnpkg.com/v8flags/-/v8flags-2.0.11.tgz#bca8f30f0d6d60612cc2c00641e6962d42ae6881" - dependencies: - user-home "^1.1.1" - -validate-npm-package-license@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.1.tgz#2804babe712ad3379459acfbe24746ab2c303fbc" - dependencies: - spdx-correct "~1.0.0" - spdx-expression-parse "~1.0.0" - -vary@~1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.0.tgz#e1e5affbbd16ae768dd2674394b9ad3022653140" - -vendors@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/vendors/-/vendors-1.0.1.tgz#37ad73c8ee417fb3d580e785312307d274847f22" - -verror@1.3.6: - version "1.3.6" - resolved "https://registry.yarnpkg.com/verror/-/verror-1.3.6.tgz#cff5df12946d297d2baaefaa2689e25be01c005c" - dependencies: - extsprintf "1.0.2" - -vinyl@^0.5.0: - version "0.5.3" - resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-0.5.3.tgz#b0455b38fc5e0cf30d4325132e461970c2091cde" - dependencies: - clone "^1.0.0" - clone-stats "^0.0.1" - replace-ext "0.0.1" - -vlq@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/vlq/-/vlq-0.2.1.tgz#14439d711891e682535467f8587c5630e4222a6c" - -vm-browserify@0.0.4: - version "0.0.4" - resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-0.0.4.tgz#5d7ea45bbef9e4a6ff65f95438e0a87c357d5a73" - dependencies: - indexof "0.0.1" - -walk-sync@^0.2.6: - version "0.2.7" - resolved "https://registry.yarnpkg.com/walk-sync/-/walk-sync-0.2.7.tgz#b49be4ee6867657aeb736978b56a29d10fa39969" - dependencies: - ensure-posix-path "^1.0.0" - matcher-collection "^1.0.0" - -walk-sync@^0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/walk-sync/-/walk-sync-0.3.1.tgz#558a16aeac8c0db59c028b73c66f397684ece465" - dependencies: - ensure-posix-path "^1.0.0" - matcher-collection "^1.0.0" - -watchpack@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.1.0.tgz#42d44627464a2fadffc9308c0f7562cfde795f24" - dependencies: - async "2.0.0-rc.4" - chokidar "^1.4.3" - graceful-fs "^4.1.2" - -wbuf@^1.1.0, wbuf@^1.4.0: - version "1.7.2" - resolved "https://registry.yarnpkg.com/wbuf/-/wbuf-1.7.2.tgz#d697b99f1f59512df2751be42769c1580b5801fe" - dependencies: - minimalistic-assert "^1.0.0" - -webpack-dev-middleware@^1.0.11, webpack-dev-middleware@^1.4.0: - version "1.9.0" - resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-1.9.0.tgz#a1c67a3dfd8a5c5d62740aa0babe61758b4c84aa" - dependencies: - memory-fs "~0.4.1" - mime "^1.3.4" - path-is-absolute "^1.0.0" - range-parser "^1.0.3" - -webpack-dev-server@2.1.0-beta.9: - version "2.1.0-beta.9" - resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-2.1.0-beta.9.tgz#45745a4a6b84849c63e3a21dfab3be7bdb897554" - dependencies: - chokidar "^1.6.0" - compression "^1.5.2" - connect-history-api-fallback "^1.3.0" - express "^4.13.3" - http-proxy-middleware "~0.17.1" - opn "4.0.2" - serve-index "^1.7.2" - sockjs "0.3.18" - sockjs-client "1.1.1" - spdy "^3.4.1" - strip-ansi "^3.0.0" - supports-color "^3.1.1" - webpack-dev-middleware "^1.4.0" - yargs "^4.7.1" - -webpack-merge@^0.14.0: - version "0.14.1" - resolved "https://registry.yarnpkg.com/webpack-merge/-/webpack-merge-0.14.1.tgz#d6bfe6d9360a024e1e7f8e6383ae735f1737cd23" - dependencies: - lodash.find "^3.2.1" - lodash.isequal "^4.2.0" - lodash.isplainobject "^3.2.0" - lodash.merge "^3.3.2" - -webpack-sources@^0.1.0, webpack-sources@^0.1.3: - version "0.1.3" - resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-0.1.3.tgz#15ce2fb79d0a1da727444ba7c757bf164294f310" - dependencies: - source-list-map "~0.1.0" - source-map "~0.5.3" - -webpack@2.1.0-beta.25: - version "2.1.0-beta.25" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-2.1.0-beta.25.tgz#c35ff4da4ee70344a2f14c35258d95412709e9ed" - dependencies: - acorn "^3.2.0" - ajv "^4.7.0" - async "^1.3.0" - clone "^1.0.2" - enhanced-resolve "^2.2.0" - interpret "^1.0.0" - loader-runner "^2.2.0" - loader-utils "^0.2.11" - memory-fs "~0.3.0" - mkdirp "~0.5.0" - node-libs-browser "^1.0.0" - object-assign "^4.0.1" - source-map "^0.5.3" - supports-color "^3.1.0" - tapable "~0.2.3" - uglify-js "~2.7.3" - watchpack "^1.0.0" - webpack-sources "^0.1.0" - yargs "^4.7.1" - -websocket-driver@>=0.5.1: - version "0.6.5" - resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.6.5.tgz#5cb2556ceb85f4373c6d8238aa691c8454e13a36" - dependencies: - websocket-extensions ">=0.1.1" - -websocket-extensions@>=0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.1.tgz#76899499c184b6ef754377c2dbb0cd6cb55d29e7" - -when@~3.6.x: - version "3.6.4" - resolved "https://registry.yarnpkg.com/when/-/when-3.6.4.tgz#473b517ec159e2b85005497a13983f095412e34e" - -whet.extend@~0.9.9: - version "0.9.9" - resolved "https://registry.yarnpkg.com/whet.extend/-/whet.extend-0.9.9.tgz#f877d5bf648c97e5aa542fadc16d6a259b9c11a1" - -which-module@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/which-module/-/which-module-1.0.0.tgz#bba63ca861948994ff307736089e3b96026c2a4f" - -which@1, which@^1.1.1, which@^1.2.10, which@^1.2.9: - version "1.2.12" - resolved "https://registry.yarnpkg.com/which/-/which-1.2.12.tgz#de67b5e450269f194909ef23ece4ebe416fa1192" - dependencies: - isexe "^1.1.1" - -wide-align@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.0.tgz#40edde802a71fea1f070da3e62dcda2e7add96ad" - dependencies: - string-width "^1.0.1" - -widest-line@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-1.0.0.tgz#0c09c85c2a94683d0d7eaf8ee097d564bf0e105c" - dependencies: - string-width "^1.0.1" - -window-size@0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.1.0.tgz#5438cd2ea93b202efa3a19fe8887aee7c94f9c9d" - -window-size@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.2.0.tgz#b4315bb4214a3d7058ebeee892e13fa24d98b075" - -wordwrap@0.0.2: - version "0.0.2" - resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.2.tgz#b79669bb42ecb409f83d583cad52ca17eaa1643f" - -wordwrap@^1.0.0, wordwrap@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" - -wordwrap@~0.0.2: - version "0.0.3" - resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107" - -wrap-ansi@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85" - dependencies: - string-width "^1.0.1" - strip-ansi "^3.0.1" - -wrappy@1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" - -write-file-atomic@^1.1.2: - version "1.2.0" - resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-1.2.0.tgz#14c66d4e4cb3ca0565c28cf3b7a6f3e4d5938fab" - dependencies: - graceful-fs "^4.1.2" - imurmurhash "^0.1.4" - slide "^1.1.5" - -write@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/write/-/write-0.2.1.tgz#5fc03828e264cea3fe91455476f7a3c566cb0757" - dependencies: - mkdirp "^0.5.1" - -xdg-basedir@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-2.0.0.tgz#edbc903cc385fc04523d966a335504b5504d1bd2" - dependencies: - os-homedir "^1.0.0" - -xml-char-classes@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/xml-char-classes/-/xml-char-classes-1.0.0.tgz#64657848a20ffc5df583a42ad8a277b4512bbc4d" - -xtend@^4.0.0, xtend@~4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af" - -y18n@^3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41" - -yallist@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.0.0.tgz#306c543835f09ee1a4cb23b7bce9ab341c91cdd4" - -yam@0.0.18: - version "0.0.18" - resolved "https://registry.yarnpkg.com/yam/-/yam-0.0.18.tgz#e5cab771f0fc80ca599814cb9c269cb8bff00e2c" - dependencies: - findup "^0.1.5" - fs-extra "^0.16.3" - lodash.merge "^3.0.2" - -yargs-parser@^2.4.1: - version "2.4.1" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-2.4.1.tgz#85568de3cf150ff49fa51825f03a8c880ddcc5c4" - dependencies: - camelcase "^3.0.0" - lodash.assign "^4.0.6" - -yargs@^4.7.1: - version "4.8.1" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-4.8.1.tgz#c0c42924ca4aaa6b0e6da1739dfb216439f9ddc0" - dependencies: - cliui "^3.2.0" - decamelize "^1.1.1" - get-caller-file "^1.0.1" - lodash.assign "^4.0.3" - 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.1" - which-module "^1.0.0" - window-size "^0.2.0" - y18n "^3.2.1" - yargs-parser "^2.4.1" - -yargs@~3.10.0: - version "3.10.0" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-3.10.0.tgz#f7ee7bd857dd7c1d2d38c0e74efbd681d1431fd1" - dependencies: - camelcase "^1.0.2" - cliui "^2.1.0" - decamelize "^1.0.0" - window-size "0.1.0" - -yn@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/yn/-/yn-1.2.0.tgz#d237a4c533f279b2b89d3acac2db4b8c795e4a63"