diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 000000000000..2a2130d6e838 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,509 @@ +# Configuration file for https://circleci.com/gh/angular/angular.js + +# Note: YAML anchors allow an object to be re-used, reducing duplication. +# The ampersand declares an alias for an object, then later the `<<: *name` +# syntax dereferences it. +# See http://blog.daemonl.com/2016/02/yaml.html +# To validate changes, use an online parser, eg. +# http://yaml-online-parser.appspot.com/ + +# CircleCI configuration version +# Version 2.1 allows for extra config reuse features +# https://circleci.com/docs/2.0/reusing-config/#getting-started-with-config-reuse +version: 2.1 + +# Workspace persisted by the `setup` job to share build artifacts with other jobs. +# https://circleci.com/docs/2.0/workflows/#using-workspaces-to-share-data-among-jobs +# https://circleci.com/blog/deep-diving-into-circleci-workspaces/ +var_workspace_location: &workspace_location ~/ + +# Executor Definitions +# https://circleci.com/docs/2.0/reusing-config/#authoring-reusable-executors +# **NOTE 1**: Pin to exact images using an ID (SHA). See https://circleci.com/docs/2.0/circleci-images/#using-a-docker-image-id-to-pin-an-image-to-a-fixed-version. +# (Using the tag in not necessary when pinning by ID, but include it anyway for documentation purposes.) +executors: + default-executor: + parameters: + resource_class: + type: string + default: medium + docker: + - image: circleci/node:14.16.1@sha256:b094e85848b43209ca83d9bb114d406fe62c75cb73b18c9d8eb1a9c6462c97d4 + resource_class: << parameters.resource_class >> + working_directory: ~/ng + cloud-sdk: + description: The docker container to use when running gcp-gcs commands + docker: + - image: google/cloud-sdk:alpine@sha256:7d0cae28cb282b76f2d9babe278c63c910d54f0cceca7a65fdf6806e2b43882e + working_directory: ~/ng + + +# Filter Definitions + +# Filter to run a job on all branches and any `v1.X.Y(-Z)` tags. +# Since the jobs need to run on tagged builds too, a `tags` section has to be explicitly specified. +# (The `branches` section could be omitted, since it defaults to all branches - just being explicit +# here). +# See also https://circleci.com/docs/2.0/workflows/#executing-workflows-for-a-git-tag. +var-filter-run-always: &run-always + filters: + branches: + only: /.*/ + tags: + only: /v1\.\d+\.\d.*/ + +# Filter to run a job when code might need to be deployed - i.e. on builds for the `master` branch. +# (Further checks are needed to determine whether a deployment is actually needed, but these are not +# possible via filters.) +var-filter-run-on-master: &run-on-master + filters: + branches: + only: + - master + tags: + ignore: /.*/ + +# Filter to run a job when code/docs might need to be deployed - i.e. on tagged builds and on builds +# for master and `v1.*.x` branches. +# (Further checks are needed to determine whether a deployment is actually needed, but these are not +# possible via filters.) +var-filter-run-on-tags-and-master-and-version-branches: &run-on-tags-and-master-and-version-branches + filters: + branches: + only: + - master + - /v1\.\d+\.x/ + tags: + only: /v1\.\d+\.\d.*/ + +# Filter to run a job when docs might need to be deployed - i.e. on builds for `v1.*.x` branches, +# which might correspond to the stable branch. +# (Further checks are needed to determine whether a deployment is actually needed, but these are not +# possible via filters.) +var-filter-run-on-version-branches: &run-on-version-branches + filters: + branches: + only: + - /v1\.\d+\.x/ + tags: + ignore: /.*/ + + +# Command Definitions +# https://circleci.com/docs/2.0/reusing-config/#authoring-reusable-commands +commands: + skip_on_pr_and_fork_builds: + description: Skip a job on pull request and fork builds + steps: + - run: + name: Skip this job if this is a pull request or fork build + # Note: Using `CIRCLE_*` env variables (instead of those defined in `env.sh` so that this + # step can be run before `init_environment`. + command: > + if [[ -n "$CIRCLE_PR_NUMBER" ]] || + [[ "$CIRCLE_PROJECT_USERNAME" != "angular" ]] || + [[ "$CIRCLE_PROJECT_REPONAME" != "angular.js" ]]; then + echo "Skipping this job, because this is either a pull request or a fork build." + circleci step halt + fi + + skip_unless_stable_branch: + description: Skip a job unless this is the stable branch + steps: + - run: + name: Skip this job unless this is the stable branch + command: > + if [[ "$DIST_TAG" != "latest" ]]; then + echo "Skipping deployment, because this is not the stable branch." + circleci step halt + fi + + skip_unless_tag_or_master_or_stable_branch: + description: Skip a job unless this is a tag or the master or stable branch + steps: + - run: + name: Skip this job unless this is a tag or the master or stable branch + command: > + if [[ "$CI_GIT_TAG" == "false" ]] && + [[ "$CI_BRANCH" != "master" ]] && + [[ "$DIST_TAG" != "latest" ]]; then + echo "Skipping this job, because this is neither a tag nor the master or stable branch." + circleci step halt + fi + + + custom_attach_workspace: + description: Attach workspace at a predefined location + steps: + - attach_workspace: + at: *workspace_location + + # Java is needed for running the Closure Compiler (during the `minall` task). + install_java: + description: Install java + steps: + - run: + name: Install java + command: | + sudo apt-get update + # Install java runtime + sudo apt-get install default-jre + + # Initializes the CI environment by setting up common environment variables. + init_environment: + description: Initializing environment (setting up variables) + steps: + - run: + name: Set up environment + environment: + CIRCLE_GIT_BASE_REVISION: << pipeline.git.base_revision >> + CIRCLE_GIT_REVISION: << pipeline.git.revision >> + command: ./.circleci/env.sh + - run: + # Configure git as the CircleCI `checkout` command does. + # This is needed because we only checkout on the setup job. + # Add GitHub to known hosts + name: Configure git + command: | + mkdir -p ~/.ssh + echo 'github.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==' >> ~/.ssh/known_hosts + git config --global url."ssh://git@github.com".insteadOf "https://github.com/" || true + git config --global gc.auto 0 || true + + init_saucelabs_environment: + description: Sets up a domain that resolves to the local host. + steps: + - run: + name: Preparing environment for running tests on Saucelabs. + command: | + # For SauceLabs jobs, we set up a domain which resolves to the machine which launched + # the tunnel. We do this because devices are sometimes not able to properly resolve + # `localhost` or `127.0.0.1` through the SauceLabs tunnel. Using a domain that does not + # resolve to anything on SauceLabs VMs ensures that such requests are always resolved + # through the tunnel, and resolve to the actual tunnel host machine (i.e. the CircleCI VM). + # More context can be found in: https://github.com/angular/angular/pull/35171. + setPublicVar SAUCE_LOCALHOST_ALIAS_DOMAIN "angular-ci.local" + setSecretVar SAUCE_ACCESS_KEY $(echo $SAUCE_ACCESS_KEY | rev) + - run: + # Sets up a local domain in the machine's host file that resolves to the local + # host. This domain is helpful in Saucelabs tests where devices are not able to + # properly resolve `localhost` or `127.0.0.1` through the sauce-connect tunnel. + name: Setting up alias domain for local host. + command: echo "127.0.0.1 $SAUCE_LOCALHOST_ALIAS_DOMAIN" | sudo tee -a /etc/hosts + + start_saucelabs: + steps: + - run: + name: Starting Saucelabs tunnel service + command: ./lib/saucelabs/sauce-service.sh start-ready-wait + + stop_saucelabs: + steps: + - run: + name: Stopping Saucelabs tunnel service + command: ./lib/saucelabs/sauce-service.sh stop + + run_e2e_tests: + parameters: + specs: + type: string + steps: + - custom_attach_workspace + - init_environment + - init_saucelabs_environment + - start_saucelabs + - run: + command: yarn grunt test:circleci-protractor --specs="<< parameters.specs >>" + no_output_timeout: 30m + - stop_saucelabs + + run_e2e_tests_jquery: + parameters: + specs: + type: string + steps: + - custom_attach_workspace + - init_environment + - init_saucelabs_environment + - start_saucelabs + - run: + environment: + USE_JQUERY: 1 + command: yarn grunt test:circleci-protractor --specs="<< parameters.specs >>" + no_output_timeout: 30m + - stop_saucelabs + +# Job definitions +# Jobs can include parameters that are passed in the workflow job invocation. +# https://circleci.com/docs/2.0/reusing-config/#authoring-parameterized-jobs +jobs: + setup: + executor: default-executor + steps: + - checkout + - init_environment + - install_java + - run: + name: Running Yarn install + command: yarn install --frozen-lockfile --non-interactive + # Yarn's requests sometimes take more than 10mins to complete. + no_output_timeout: 45m + - run: yarn grunt package + # Persist any changes at this point to be reused by further jobs. + # **NOTE**: To add new content to the workspace, always persist on the same root. + - persist_to_workspace: + root: *workspace_location + paths: + - ./ng + + lint: + executor: default-executor + steps: + - custom_attach_workspace + - init_environment + - run: yarn grunt ci-checks + - run: yarn commitplease "$CI_COMMIT_RANGE" + - run: yarn grunt validate-angular-files + + unit-test: + executor: + name: default-executor + steps: + - custom_attach_workspace + - init_environment + - install_java + - init_saucelabs_environment + - run: yarn grunt test:promises-aplus + - run: + command: yarn grunt test:jqlite --browsers="$BROWSERS" --reporters=spec + no_output_timeout: 10m + - run: + command: yarn grunt test:modules --browsers="$BROWSERS" --reporters=spec + no_output_timeout: 10m + - run: + command: yarn grunt test:docs --browsers="$BROWSERS" --reporters=spec + no_output_timeout: 10m + + unit-test-jquery: + executor: + name: default-executor + steps: + - custom_attach_workspace + - init_environment + - init_saucelabs_environment + - run: + command: yarn grunt test:jquery --browsers="$BROWSERS" --reporters=spec + no_output_timeout: 10m + - run: + command: yarn grunt test:jquery-2.2 --browsers="$BROWSERS" --reporters=spec + no_output_timeout: 10m + - run: + command: yarn grunt test:jquery-2.1 --browsers="$BROWSERS" --reporters=spec + no_output_timeout: 10m + + e2e-test-1: + executor: + name: default-executor + steps: + - run_e2e_tests: + specs: test/e2e/tests/**/*.js + + e2e-test-2a: + executor: + name: default-executor + steps: + - run_e2e_tests: + specs: build/docs/ptore2e/example-ng*/**/default_test.js + + e2e-test-2b: + executor: + name: default-executor + steps: + - run_e2e_tests: + specs: "build/docs/ptore2e/!(example-ng*)/**/default_test.js" + + e2e-test-jquery-1: + executor: + name: default-executor + steps: + - run_e2e_tests_jquery: + specs: test/e2e/tests/**/*.js + + e2e-test-jquery-2a: + executor: + name: default-executor + steps: + - run_e2e_tests_jquery: + specs: build/docs/ptore2e/example-ng*/**/jquery_test.js + + e2e-test-jquery-2b: + executor: + name: default-executor + steps: + - run_e2e_tests_jquery: + specs: build/docs/ptore2e/!(example-ng*)/**/jquery_test.js + + prepare-deployment: + executor: + name: default-executor + steps: + - skip_on_pr_and_fork_builds + - custom_attach_workspace + - init_environment + - run: yarn grunt prepareDeploy + # Write the deployment files to the workspace to be used by deploy-docs and deploy-code + - persist_to_workspace: + root: *workspace_location + paths: + - ./ng + + # The `deploy-code-files` job should only run when all of these conditions are true for the build: + # - It is for the `angular/angular.js` repository (not a fork). + # - It is not for a pull request. + # - It is for a tag or the master branch or the stable branch(*). + # + # *: The stable branch is the one that has the value `latest` in `package.json > distTag`. + deploy-code-files: + executor: + name: cloud-sdk + steps: + - skip_on_pr_and_fork_builds + - custom_attach_workspace + - init_environment + - skip_unless_tag_or_master_or_stable_branch + - run: ls scripts/code.angularjs.org-firebase/deploy + - run: + name: Authenticate and configure Docker + command: | + echo $GCLOUD_SERVICE_KEY | gcloud auth activate-service-account --key-file=- + gcloud --quiet config set project ${GOOGLE_PROJECT_ID} + - run: + name: Sync files to code.angularjs.org + command: | + gsutil -m rsync -r scripts/code.angularjs.org-firebase/deploy gs://code-angularjs-org-338b8.appspot.com + + # The `deploy-code-firebase` job should only run when all of these conditions are true for the build: + # - It is for the `angular/angular.js` repository (not a fork). + # - It is not for a pull request. + # - It is for the master branch. + # (This is enforced via job filters, so we don't need to a step to check it here.) + deploy-code-firebase: + executor: + name: default-executor + steps: + - skip_on_pr_and_fork_builds + - custom_attach_workspace + - init_environment + # Install dependencies for Firebase functions to prevent parsing errors during deployment. + # See https://github.com/angular/angular.js/pull/16453. + - run: + name: Install dependencies in `scripts/code.angularjs.org-firebase/functions/`. + working_directory: scripts/code.angularjs.org-firebase/functions + command: yarn install --frozen-lockfile --ignore-engines --non-interactive + - run: + name: Deploy to Firebase from `scripts/code.angularjs.org-firebase/`. + working_directory: scripts/code.angularjs.org-firebase + command: | + # Do not use `yarn firebase` as that causes the Firebase CLI to look for `firebase.json` + # in the root directory, even if run from inside `scripts/code.angularjs.org-firebase/`. + firebase=$(yarn bin)/firebase + $firebase use + $firebase deploy --message "Commit:\ $CI_COMMIT" --non-interactive --token "$FIREBASE_TOKEN" + + # The `deploy-docs` job should only run when all of these conditions are true for the build: + # - It is for the `angular/angular.js` repository (not a fork). + # - It is not for a pull request. + # - It is for the stable branch(*). + # + # *: The stable branch is the one that has the value `latest` in `package.json > distTag`. + deploy-docs: + executor: + name: default-executor + steps: + - skip_on_pr_and_fork_builds + - custom_attach_workspace + - init_environment + - skip_unless_stable_branch + # Install dependencies for Firebase functions to prevent parsing errors during deployment. + # See https://github.com/angular/angular.js/pull/16453. + - run: + name: Install dependencies in `scripts/docs.angularjs.org-firebase/functions/`. + working_directory: scripts/docs.angularjs.org-firebase/functions + command: yarn install --frozen-lockfile --ignore-engines --non-interactive + - run: + name: Deploy to Firebase from `scripts/docs.angularjs.org-firebase/`. + working_directory: scripts/docs.angularjs.org-firebase + command: | + # Do not use `yarn firebase` as that causes the Firebase CLI to look for `firebase.json` + # in the root directory, even if run from inside `scripts/docs.angularjs.org-firebase/`. + firebase=$(yarn bin)/firebase + $firebase use + $firebase deploy --message "Commit:\ $CI_COMMIT" --non-interactive --token "$FIREBASE_TOKEN" + +workflows: + version: 2 + default_workflow: + jobs: + - setup: + <<: *run-always + - lint: + <<: *run-always + requires: + - setup + - unit-test: + <<: *run-always + requires: + - setup + - unit-test-jquery: + <<: *run-always + requires: + - setup + - e2e-test-1: + <<: *run-always + requires: + - setup + - e2e-test-2a: + <<: *run-always + requires: + - setup + - e2e-test-2b: + <<: *run-always + requires: + - setup + - e2e-test-jquery-1: + <<: *run-always + requires: + - setup + - e2e-test-jquery-2a: + <<: *run-always + requires: + - setup + - e2e-test-jquery-2b: + <<: *run-always + requires: + - setup + - prepare-deployment: + <<: *run-on-tags-and-master-and-version-branches + requires: + - setup + - lint + - unit-test + - unit-test-jquery + - e2e-test-1 + - e2e-test-2a + - e2e-test-2b + - e2e-test-jquery-1 + - e2e-test-jquery-2a + - e2e-test-jquery-2b + - deploy-code-files: + <<: *run-on-tags-and-master-and-version-branches + requires: + - prepare-deployment + - deploy-code-firebase: + <<: *run-on-master + requires: + - prepare-deployment + - deploy-docs: + <<: *run-on-version-branches + requires: + - prepare-deployment diff --git a/.circleci/env-helpers.inc.sh b/.circleci/env-helpers.inc.sh new file mode 100644 index 000000000000..5fa1263e112f --- /dev/null +++ b/.circleci/env-helpers.inc.sh @@ -0,0 +1,73 @@ +#################################################################################################### +# Helpers for defining environment variables for CircleCI. +# +# In CircleCI, each step runs in a new shell. The way to share ENV variables across steps is to +# export them from `$BASH_ENV`, which is automatically sourced at the beginning of every step (for +# the default `bash` shell). +# +# See also https://circleci.com/docs/2.0/env-vars/#using-bash_env-to-set-environment-variables. +#################################################################################################### + +# Set and print an environment variable. +# +# Use this function for setting environment variables that are public, i.e. it is OK for them to be +# visible to anyone through the CI logs. +# +# Usage: `setPublicVar ` +function setPublicVar() { + setSecretVar $1 "$2"; + echo "$1=$2"; +} + +# Set (without printing) an environment variable. +# +# Use this function for setting environment variables that are secret, i.e. should not be visible to +# everyone through the CI logs. +# +# Usage: `setSecretVar ` +function setSecretVar() { + # WARNING: Secrets (e.g. passwords, access tokens) should NOT be printed. + # (Keep original shell options to restore at the end.) + local -r originalShellOptions=$(set +o); + set +x -eu -o pipefail; + + echo "export $1=\"${2:-}\";" >> $BASH_ENV; + + # Restore original shell options. + eval "$originalShellOptions"; +} + + +# Create a function to set an environment variable, when called. +# +# Use this function for creating setter for public environment variables that require expensive or +# time-consuming computaions and may not be needed. When needed, you can call this function to set +# the environment variable (which will be available through `$BASH_ENV` from that point onwards). +# +# Arguments: +# - ``: The name of the environment variable. The generated setter function will be +# `setPublicVar_`. +# - ``: The code to run to compute the value for the variable. Since this code should be +# executed lazily, it must be properly escaped. For example: +# ```sh +# # DO NOT do this: +# createPublicVarSetter MY_VAR "$(whoami)"; # `whoami` will be evaluated eagerly +# +# # DO this isntead: +# createPublicVarSetter MY_VAR "\$(whoami)"; # `whoami` will NOT be evaluated eagerly +# ``` +# +# Usage: `createPublicVarSetter ` +# +# Example: +# ```sh +# createPublicVarSetter MY_VAR 'echo "FOO"'; +# echo $MY_VAR; # Not defined +# +# setPublicVar_MY_VAR; +# source $BASH_ENV; +# echo $MY_VAR; # FOO +# ``` +function createPublicVarSetter() { + echo "setPublicVar_$1() { setPublicVar $1 \"$2\"; }" >> $BASH_ENV; +} diff --git a/.circleci/env.sh b/.circleci/env.sh new file mode 100755 index 000000000000..338371017ccb --- /dev/null +++ b/.circleci/env.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash + +# Variables +readonly projectDir=$(realpath "$(dirname ${BASH_SOURCE[0]})/..") +readonly envHelpersPath="$projectDir/.circleci/env-helpers.inc.sh"; + +# Load helpers and make them available everywhere (through `$BASH_ENV`). +source $envHelpersPath; +echo "source $envHelpersPath;" >> $BASH_ENV; + +#################################################################################################### +# Define PUBLIC environment variables for CircleCI. +#################################################################################################### +# See https://circleci.com/docs/2.0/env-vars/#built-in-environment-variables for more info. +#################################################################################################### +setPublicVar CI "$CI" +setPublicVar PROJECT_ROOT "$projectDir"; +# This is the branch being built; e.g. `pull/12345` for PR builds. +setPublicVar CI_BRANCH "$CIRCLE_BRANCH"; +setPublicVar CI_BUILD_URL "$CIRCLE_BUILD_URL"; +setPublicVar CI_COMMIT "$CIRCLE_SHA1"; +setPublicVar CI_GIT_BASE_REVISION "${CIRCLE_GIT_BASE_REVISION}"; +setPublicVar CI_GIT_REVISION "${CIRCLE_GIT_REVISION}"; +setPublicVar CI_GIT_TAG "${CIRCLE_TAG:-false}"; +setPublicVar CI_COMMIT_RANGE "$CIRCLE_GIT_BASE_REVISION..$CIRCLE_GIT_REVISION"; +setPublicVar CI_PULL_REQUEST "${CIRCLE_PR_NUMBER:-false}"; +setPublicVar CI_REPO_NAME "$CIRCLE_PROJECT_REPONAME"; +setPublicVar CI_REPO_OWNER "$CIRCLE_PROJECT_USERNAME"; +setPublicVar CI_PR_REPONAME "$CIRCLE_PR_REPONAME"; +setPublicVar CI_PR_USERNAME "$CIRCLE_PR_USERNAME"; + + +#################################################################################################### +# Define SauceLabs environment variables for CircleCI. +#################################################################################################### +setPublicVar BROWSER_PROVIDER "saucelabs" + +# The currently latest-1 version of desktop Safari on Saucelabs (v12.0) is unstable and disconnects +# consistently. The latest version (v12.1) works fine. +# TODO: Add `SL_Safari-1` back, once it no longer corresponds to v12.0. +setPublicVar BROWSERS "SL_Chrome,SL_Chrome-1,\ +SL_Firefox,SL_Firefox-1,\ +SL_Safari,\ +SL_iOS,SL_iOS-1,\ +SL_IE_9,SL_IE_10,SL_IE_11,\ +SL_EDGE,SL_EDGE-1" + +setPublicVar SAUCE_LOG_FILE /tmp/angular/sauce-connect.log +setPublicVar SAUCE_READY_FILE /tmp/angular/sauce-connect-ready-file.lock +setPublicVar SAUCE_PID_FILE /tmp/angular/sauce-connect-pid-file.lock +setPublicVar SAUCE_TUNNEL_IDENTIFIER "angularjs-framework-${CIRCLE_BUILD_NUM}-${CIRCLE_NODE_INDEX}" +# Amount of seconds we wait for sauceconnect to establish a tunnel instance. In order to not +# acquire CircleCI instances for too long if sauceconnect failed, we need a connect timeout. +setPublicVar SAUCE_READY_FILE_TIMEOUT 120 + +#################################################################################################### +# Define additional environment variables +#################################################################################################### + +# NOTE: Make sure the tools used to compute this are available in all executors in `config.yml`. +setPublicVar DIST_TAG $( cat package.json | grep distTag | sed -E 's/^\s*"distTag"\s*:\s*"([^"]+)"\s*,\s*$/\1/' ) + +#################################################################################################### +#################################################################################################### +## Source `$BASH_ENV` to make the variables available immediately. ## +## *** NOTE: This must remain the last command in this script. *** ## +#################################################################################################### +#################################################################################################### +source $BASH_ENV; diff --git a/.editorconfig b/.editorconfig index f6a54e4dd2c5..a6bc2855214e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,4 +1,4 @@ -# http://editorconfig.org +# https://editorconfig.org root = true diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 000000000000..6d8222eb45db --- /dev/null +++ b/.eslintignore @@ -0,0 +1,10 @@ +build/** +docs/app/assets/js/angular-bootstrap/** +docs/config/templates/** +node_modules/** +lib/htmlparser/** +src/angular.bind.js +src/ngParseExt/ucd.js +i18n/closure/** +tmp/** +vendor/** diff --git a/.eslintrc-base.json b/.eslintrc-base.json new file mode 100644 index 000000000000..ee3a411bb2d7 --- /dev/null +++ b/.eslintrc-base.json @@ -0,0 +1,117 @@ +{ + "rules": { + // Rules are divided into sections from http://eslint.org/docs/rules/ + + // Possible errors + "comma-dangle": ["error", "never"], + "no-cond-assign": ["error", "except-parens"], + "no-constant-condition": ["error", {"checkLoops": false}], + "no-control-regex": "error", + "no-debugger": "error", + "no-dupe-args": "error", + "no-dupe-keys": "error", + "no-duplicate-case": "error", + "no-empty-character-class": "error", + "no-empty": "error", + "no-ex-assign": "error", + "no-extra-boolean-cast": "error", + "no-extra-semi": "error", + "no-func-assign": "error", + "no-inner-declarations": "error", + "no-invalid-regexp": "error", + "no-irregular-whitespace": "error", + "no-negated-in-lhs": "error", + "no-obj-calls": "error", + "no-regex-spaces": "error", + "no-sparse-arrays": "error", + "no-unreachable": "error", + "use-isnan": "error", + "no-unsafe-finally": "error", + "valid-typeof": "error", + "no-unexpected-multiline": "error", + + // Best practices + "accessor-pairs": "error", + "array-callback-return": "error", + "eqeqeq": ["error", "allow-null"], + "no-alert": "error", + "no-caller": "error", + "no-case-declarations": "error", + "no-eval": "error", + "no-extend-native": "error", + "no-extra-bind": "error", + "no-extra-label": "error", + "no-fallthrough": "error", + "no-floating-decimal": "error", + "no-implied-eval": "error", + "no-invalid-this": "error", + "no-iterator": "error", + "no-multi-str": "error", + "no-new-func": "error", + "no-new-wrappers": "error", + "no-new": "error", + "no-octal-escape": "error", + "no-octal": "error", + "no-proto": "error", + "no-redeclare": "error", + "no-return-assign": "error", + "no-script-url": "error", + "no-self-assign": "error", + "no-self-compare": "error", + "no-sequences": "error", + "no-throw-literal": "error", + "no-unmodified-loop-condition": "error", + "no-unused-expressions": "error", + "no-unused-labels": "error", + "no-useless-call": "error", + "no-useless-concat": "error", + "no-useless-escape": "error", + "no-void": "error", + "no-with": "error", + "radix": "error", + "wrap-iife": ["error", "inside"], + + // Strict mode + "strict": ["error", "global"], + + // Variables + "no-delete-var": "error", + "no-label-var": "error", + "no-restricted-globals": ["error", "event"], + "no-shadow-restricted-names": "error", + "no-undef-init": "error", + "no-undef": "error", + "no-unused-vars": ["error", { "vars": "local", "args": "none" }], + + // Node.js + "handle-callback-err": "error", + + // Stylistic issues + "array-bracket-spacing": ["error", "never"], + "brace-style": ["error", "1tbs", { "allowSingleLine": true }], + "comma-style": ["error", "last"], + "eol-last": "error", + "keyword-spacing": "error", + "linebreak-style": ["error", "unix"], + "max-len": ["error", { "code": 200, "ignoreComments": true, "ignoreUrls": true }], + "new-cap": "error", + "new-parens": "error", + "no-array-constructor": "error", + "no-bitwise": "error", + "no-mixed-spaces-and-tabs": "error", + "no-multiple-empty-lines": ["error", { "max": 3, "maxEOF": 1 }], + "no-whitespace-before-property": "error", + "no-spaced-func": "error", + "no-trailing-spaces": "error", + "no-unneeded-ternary": "error", + "quotes": ["error", "single"], + "semi-spacing": "error", + "semi": "error", + "space-before-blocks": ["error", "always"], + "space-before-function-paren": ["error", "never"], + "space-in-parens": ["error", "never"], + "space-infix-ops": "error", + "space-unary-ops": ["error", { "words": true, "nonwords": false }], + "unicode-bom": ["error", "never"] + } +} diff --git a/.eslintrc-browser.json b/.eslintrc-browser.json new file mode 100644 index 000000000000..44024664ae8f --- /dev/null +++ b/.eslintrc-browser.json @@ -0,0 +1,17 @@ +{ + "extends": "./.eslintrc-base.json", + + "env": { + // Note: don't set `"browser": true`; code in "src/" should be compatible with + // non-browser environments like Node.js with a custom window implementation + // like jsdom. All browser globals should be taken from window. + "browser": false, + "node": false + }, + + "globals": { + "window": false, + + "angular": false + } +} diff --git a/.eslintrc-node.json b/.eslintrc-node.json new file mode 100644 index 000000000000..c16a8a883837 --- /dev/null +++ b/.eslintrc-node.json @@ -0,0 +1,13 @@ +{ + "extends": "./.eslintrc-base.json", + "env": { + "browser": false, + "node": true + }, + "parserOptions": { + "ecmaVersion": 2017 + }, + "plugins": [ + "promise" + ] +} diff --git a/.eslintrc-todo.json b/.eslintrc-todo.json new file mode 100644 index 000000000000..a7b24d7a05b0 --- /dev/null +++ b/.eslintrc-todo.json @@ -0,0 +1,25 @@ +{ + // This config contains proposed rules that we'd like to have enabled but haven't + // converted the code to adhere yet. If a decision comes to not enable one of these + // rules, it should be removed from the file. Every rule that got enabled in the + // end should be moved from here to a respective section in .eslintrc.json + + "rules": { + // Rules are divided into sections from http://eslint.org/docs/rules/ + + // Best practices + "complexity": ["error", 10], + "dot-notation": "error", + "dot-location": ["error", "property"], + + // Stylistic issues + "block-spacing": ["error", "always"], + "comma-spacing": "error", + "id-denylist": ["error", "event"], + "indent": ["error", 2], + "key-spacing": ["error", { "beforeColon": false, "afterColon": true, "mode": "minimum" }], + "object-curly-spacing": ["error", "never"], + "object-property-newline": ["error", { "allowMultiplePropertiesPerLine": true }], + "operator-linebreak": ["error", "after", { "overrides": { "?": "before", ":": "before" }}] + } +} diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 000000000000..d8de7a976909 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,4 @@ +{ + "root": true, + "extends": "./.eslintrc-node.json" +} diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 000000000000..f5513f23390c --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,42 @@ +# AngularJS is in LTS mode +We are no longer accepting changes that are not critical bug fixes into this project. +See https://blog.angular.io/stable-angularjs-and-long-term-support-7e077635ee9c for more detail. + + + + + +**I'm submitting a ...** + +- [ ] regression from 1.7.0 +- [ ] security issue +- [ ] issue caused by a new browser version +- [ ] other + +**Current behavior:** + + +**Expected / new behavior:** + + +**Minimal reproduction of the problem with instructions:** + + +**AngularJS version:** 1.8.x + + +**Browser:** [all | Chrome XX | Firefox XX | Edge XX | IE XX | Safari XX | Mobile Chrome XX | Android X.X Web Browser | iOS XX Safari | iOS XX UIWebView | iOS XX WKWebView | Opera XX ] + + +**Anything else:** + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000000..fd23b045065a --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,29 @@ +# AngularJS is in LTS mode +We are no longer accepting changes that are not critical bug fixes into this project. +See https://blog.angular.io/stable-angularjs-and-long-term-support-7e077635ee9c for more detail. + + +**Does this PR fix a regression since 1.7.0, a security flaw, or a problem caused by a new browser version?** + + + + +**What is the current behavior? (You can also link to an open issue here)** + + + +**What is the new behavior (if this is a feature change)?** + + + +**Does this PR introduce a breaking change?** + + + +**Please check if the PR fulfills these requirements** +- [ ] The commit message follows our [guidelines](https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#commits) +- [ ] Fix/Feature: [Docs](https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#documentation) have been added/updated +- [ ] Fix/Feature: Tests have been added; existing tests pass + +**Other information**: + diff --git a/.gitignore b/.gitignore index dcfa68efd8e1..9641ed4fd609 100644 --- a/.gitignore +++ b/.gitignore @@ -9,9 +9,9 @@ performance/temp*.html *~ *.swp angular.js.tmproj -/node_modules/ -bower_components/ +node_modules/ angular.xcodeproj +.firebase/ .idea *.iml .agignore @@ -19,4 +19,9 @@ angular.xcodeproj libpeerconnection.log npm-debug.log /tmp/ -/scripts/bower/bower-* +.vscode +*.log +*.stackdump +scripts/code.angularjs.org-firebase/deploy +scripts/docs.angularjs.org-firebase/deploy +scripts/docs.angularjs.org-firebase/functions/content diff --git a/.jscs.json b/.jscs.json deleted file mode 100644 index 4d2b16f30bcd..000000000000 --- a/.jscs.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "excludeFiles": ["src/ngLocale/**"], - "disallowKeywords": ["with"], - "disallowKeywordsOnNewLine": ["else"], - "disallowMixedSpacesAndTabs": true, - "disallowMultipleLineStrings": true, - "disallowNewlineBeforeBlockStatements": true, - "disallowSpaceAfterObjectKeys": true, - "disallowSpaceAfterPrefixUnaryOperators": ["++", "--", "+", "-", "~", "!"], - "disallowSpaceBeforeBinaryOperators": [","], - "disallowSpaceBeforePostfixUnaryOperators": ["++", "--"], - "disallowSpacesInAnonymousFunctionExpression": { - "beforeOpeningRoundBrace": true - }, - "disallowSpacesInCallExpression": true, - "disallowSpacesInFunctionDeclaration": { - "beforeOpeningRoundBrace": true - }, - "disallowSpacesInNamedFunctionExpression": { - "beforeOpeningRoundBrace": true - }, - "disallowSpacesInsideArrayBrackets": true, - "requireSpaceBeforeKeywords": [ - "else", - "while", - "catch" - ], - "disallowSpacesInsideParentheses": true, - "disallowTrailingComma": true, - "disallowTrailingWhitespace": true, - "requireCommaBeforeLineBreak": true, - "requireLineFeedAtFileEnd": true, - "requireSpaceAfterBinaryOperators": ["?", ":", "+", "-", "/", "*", "%", "==", "===", "!=", "!==", ">", ">=", "<", "<=", "&&", "||"], - "requireSpaceBeforeBinaryOperators": ["?", ":", "+", "-", "/", "*", "%", "==", "===", "!=", "!==", ">", ">=", "<", "<=", "&&", "||"], - "requireSpaceAfterKeywords": ["if", "else", "for", "while", "do", "switch", "return", "try", "catch"], - "requireSpaceBeforeBlockStatements": true, - "requireSpacesInConditionalExpression": { - "afterTest": true, - "beforeConsequent": true, - "afterConsequent": true, - "beforeAlternate": true - }, - "requireSpacesInForStatement": true, - "requireSpacesInFunction": { - "beforeOpeningCurlyBrace": true - }, - "validateLineBreaks": "LF" -} diff --git a/.jscs.json.todo b/.jscs.json.todo deleted file mode 100644 index 5e52d23ed7a6..000000000000 --- a/.jscs.json.todo +++ /dev/null @@ -1,15 +0,0 @@ -// This is an incomplete TODO list of checks we want to start enforcing -// -// The goal is to enable these checks one by one by moving them to .jscs.json along with commits -// that correct the existing code base issues and make the new check pass. - -{ - "validateParameterSeparator": ", ", // Re-assert this rule when JSCS allows multiple spaces - "requireCurlyBraces": ["if", "else", "for", "while", "do", "try", "catch"], - "disallowImplicitTypeConversion": ["string"], - "disallowMultipleLineBreaks": true, - "validateJSDoc": { - "checkParamNames": true, - "requireParamTypes": true - } -} diff --git a/.jshintignore b/.jshintignore deleted file mode 100644 index e9cc4f260316..000000000000 --- a/.jshintignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules/** -lib/htmlparser/** diff --git a/.jshintrc b/.jshintrc deleted file mode 100644 index 7fbaafbc0a8c..000000000000 --- a/.jshintrc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "extends": ".jshintrc-base", - "node": true, - "globals": {} -} diff --git a/.jshintrc-base b/.jshintrc-base deleted file mode 100644 index c4ac5e2666bf..000000000000 --- a/.jshintrc-base +++ /dev/null @@ -1,19 +0,0 @@ -{ - "bitwise": true, - "immed": true, - "newcap": true, - "noarg": true, - "noempty": true, - "nonew": true, - "trailing": true, - "maxlen": 200, - "boss": true, - "eqnull": true, - "expr": true, - "globalstrict": true, - "laxbreak": true, - "loopfunc": true, - "sub": true, - "undef": true, - "indent": 2 -} diff --git a/.mailmap b/.mailmap new file mode 100644 index 000000000000..f1a2dc0b18e0 --- /dev/null +++ b/.mailmap @@ -0,0 +1,29 @@ +Andres Ornelas +Caitlin Potter +Caitlin Potter +Di Peng +Di Peng +Georgios Kalpakas +Georgios Kalpakas +Julie Ralph +Lucas Galfaso +Martin Staffa +Martin Staffa +Matias Niemelä +Michał Gołębiowski-Owczarek +Misko Hevery +Misko Hevery +Igor Minar +Igor Minar +Igor Minar +Igor Minar +Pawel Kozlowski +Peter Bacon Darwin +Rodric Haddad +Shahar Talmi +Shahar Talmi +Shyam Seshadri +Shyam Seshadri +Vojta Jina +Vojta Jina +Vojta Jina diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000000..6b17d228d335 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +14.16.1 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 87f5effad9bd..000000000000 --- a/.travis.yml +++ /dev/null @@ -1,71 +0,0 @@ -language: node_js -node_js: - - '0.10' - -cache: - directories: - - node_modules - - bower_components - - docs/bower_components - -branches: - except: - - /^g3_.*$/ - -env: - matrix: - - JOB=unit BROWSER_PROVIDER=saucelabs - - JOB=docs-e2e BROWSER_PROVIDER=saucelabs - - JOB=e2e TEST_TARGET=jqlite BROWSER_PROVIDER=saucelabs - - JOB=e2e TEST_TARGET=jquery BROWSER_PROVIDER=saucelabs - - JOB=unit BROWSER_PROVIDER=browserstack - - JOB=docs-e2e BROWSER_PROVIDER=browserstack - - JOB=e2e TEST_TARGET=jqlite BROWSER_PROVIDER=browserstack - - JOB=e2e TEST_TARGET=jquery BROWSER_PROVIDER=browserstack - global: - - SAUCE_USERNAME=angular-ci - - SAUCE_ACCESS_KEY=9b988f434ff8-fbca-8aa4-4ae3-35442987 - - BROWSER_STACK_USERNAME=VojtaJina - - BROWSER_STACK_ACCESS_KEY=QCQJ1ZpWXpBkSwEdD8ev - - LOGS_DIR=/tmp/angular-build/logs - - BROWSER_PROVIDER_READY_FILE=/tmp/browsersprovider-tunnel-ready - -matrix: - allow_failures: - - env: "JOB=unit BROWSER_PROVIDER=browserstack" - - env: "JOB=docs-e2e BROWSER_PROVIDER=browserstack" - - env: "JOB=e2e TEST_TARGET=jqlite BROWSER_PROVIDER=browserstack" - - env: "JOB=e2e TEST_TARGET=jquery BROWSER_PROVIDER=browserstack" - -install: - # Check the size of caches - - du -sh ./node_modules ./bower_components/ ./docs/bower_components/ || true - # - npm config set registry http://23.251.144.68 - # Disable the spinner, it looks bad on Travis - - npm config set spin false - # Log HTTP requests - - npm config set loglevel http - - npm install -g npm@2.5 - # Instal npm dependecies and ensure that npm cache is not stale - - scripts/npm/install-dependencies.sh - -before_script: - - mkdir -p $LOGS_DIR - - ./scripts/travis/start_browser_provider.sh - - npm install -g grunt-cli - - grunt package - - ./scripts/travis/wait_for_browser_provider.sh - -script: - - ./scripts/travis/build.sh - -after_script: - - ./scripts/travis/print_logs.sh - -notifications: - webhooks: - urls: - - https://webhooks.gitter.im/e/d2120f3f2bb39a4531b2 - on_success: change # options: [always|never|change] default: always - on_failure: always # options: [always|never|change] default: always - on_start: false # default: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 8650464d59f3..c720bd43ffa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8643 @@ +**AngularJS support has officially ended as of January 2022. +[See what ending support means](https://docs.angularjs.org/misc/version-support-status) +and [read the end of life announcement](https://goo.gle/angularjs-end-of-life).** + +**Visit [angular.io](https://angular.io) for the actively supported Angular.** + + +# 1.8.3 ultimate-farewell (2022-04-07) + +One final release of AngularJS in order to update package README files on npm. + + +# 1.8.2 meteoric-mining (2020-10-21) + +## Bug Fixes +- **$sceDelegate:** ensure that `resourceUrlWhitelist()` is identical to `trustedResourceUrlList()` + ([e41f01](https://github.com/angular/angular.js/commit/e41f018959934bfbf982ba996cd654b1fce88d43), + [#17090](https://github.com/angular/angular.js/issues/17090)) + + + +# 1.8.1 mutually-supporting (2020-09-30) + +## Bug Fixes +- **$sanitize:** do not trigger CSP alert/report in Firefox and Chrome + ([2fab3d](https://github.com/angular/angular.js/commit/2fab3d4e00f4fe35bfa3cf255160cb97404baf24)) + +## Refactorings + +- **SanitizeUriProvider:** remove usages of whitelist + ([76738102](https://github.com/angular/angular.js/commit/767381020d88bda2855ac87ca6f00748907e14ff)) +- **httpProvider:** remove usages of whitelist and blacklist + ([c953af6b](https://github.com/angular/angular.js/commit/c953af6b8cfeefe4acc0ca358550eed5da8cfe00)) +- **sceDelegateProvider:** remove usages of whitelist and blacklist + ([a206e267](https://github.com/angular/angular.js/commit/a206e2675c351c3cdcde3402978126774c1c5df9)) + +## Deprecation Notices + +- Deprecated ~~`$compileProvider.aHrefSanitizationWhitelist`~~. + It is now [`aHrefSanitizationTrustedUrlList`](https://docs.angularjs.org/api/ng/provider/$compileProvider#aHrefSanitizationTrustedUrlList). +- Deprecated ~~`$compileProvider.imgSrcSanitizationWhitelist`~~. + It is now [`imgSrcSanitizationTrustedUrlList`](https://docs.angularjs.org/api/ng/provider/$compileProvider#imgSrcSanitizationTrustedUrlList). +- Deprecated ~~`$httpProvider.xsrfWhitelistedOrigins`~~. + It is now [`xsrfTrustedOrigins`](https://docs.angularjs.org/api/ng/provider/$httpProvider#xsrfTrustedOrigins). +- Deprecated ~~`$sceDelegateProvider.resourceUrlWhitelist`~~. + It is now [`trustedResourceUrlList`](https://docs.angularjs.org/api/ng/provider/$sceDelegateProvider#trustedResourceUrlList). +- Deprecated ~~`$sceDelegateProvider.resourceUrlBlacklist`~~. + It is now [`bannedResourceUrlList`](https://docs.angularjs.org/api/ng/provider/$sceDelegateProvider#bannedResourceUrlList). + +For the purposes of backward compatibility, the previous symbols are aliased to their new symbol. + + + +# 1.8.0 nested-vaccination (2020-06-01) + +_This release contains a breaking change to resolve a security issue which was discovered by +Krzysztof Kotowicz(@koto); and independently by Esben Sparre Andreasen (@esbena) while +performing a Variant Analysis of [CVE-2020-11022](https://github.com/advisories/GHSA-gxr4-xjj5-5px2) +which itself was found and reported by Masato Kinugawa (@masatokinugawa)._ + +## Bug Fixes +- **jqLite:** + - prevent possible XSS due to regex-based HTML replacement + ([2df43c](https://github.com/angular/angular.js/commit/2df43c07779137d1bddf7f3b282a1287a8634acd)) + +## Breaking Changes + +### **jqLite** due to: + - **[2df43c](https://github.com/angular/angular.js/commit/2df43c07779137d1bddf7f3b282a1287a8634acd)**: prevent possible XSS due to regex-based HTML replacement + +JqLite no longer turns XHTML-like strings like `
` to sibling elements `
` +when not in XHTML mode. Instead it will leave them as-is. The browser, in non-XHTML mode, will convert these to: +`
`. + +This is a security fix to avoid an XSS vulnerability if a new jqLite element is created from a user-controlled HTML string. +If you must have this functionality and understand the risk involved then it is posible to restore the original behavior by calling + +```js +angular.UNSAFE_restoreLegacyJqLiteXHTMLReplacement(); +``` + +But you should adjust your code for this change and remove your use of this function as soon as possible. + +Note that this only patches jqLite. If you use jQuery 3.5.0 or newer, please read the [jQuery 3.5 upgrade guide](https://jquery.com/upgrade-guide/3.5/) for more details about the workarounds. + + + +# 1.7.9 pollution-eradication (2019-11-19) + +## Bug Fixes +- **angular.merge:** do not merge __proto__ property + ([726f49](https://github.com/angular/angular.js/commit/726f49dcf6c23106ddaf5cfd5e2e592841db743a)) +
(Thanks to the [Snyk Security Research Team](https://snyk.io/blog/snyk-research-team-discovers-severe-prototype-pollution-security-vulnerabilities-affecting-all-versions-of-lodash/) for identifyng this issue.) +- **ngStyle:** correctly remove old style when new style value is invalid + ([5edd25](https://github.com/angular/angular.js/commit/5edd25364f617083363dc2bd61f9230b38267578), + [#16860](https://github.com/angular/angular.js/issues/16860), + [#16868](https://github.com/angular/angular.js/issues/16868)) + + + +# 1.7.8 enthusiastic-oblation (2019-03-11) + + +## Bug Fixes +- **required:** correctly validate required on non-input element surrounded by ngIf + ([a4c7bd](https://github.com/angular/angular.js/commit/a4c7bdccd76c39c30e33f6215da9a00cc8acde2c), + [#16830](https://github.com/angular/angular.js/issues/16830), + [#16836](https://github.com/angular/angular.js/issues/16836)) + + + +# 1.7.7 kingly-exiting (2019-02-04) + +## Bug Fixes +- **ngRequired:** set error correctly when inside ngRepeat and false by default + ([5ad4f5](https://github.com/angular/angular.js/commit/5ad4f5562c37b1cb575e3e5fddd96e9dd10408e2), + [#16814](https://github.com/angular/angular.js/issues/16814), + [#16820](https://github.com/angular/angular.js/issues/16820)) + + + +# 1.7.6 gravity-manipulation (2019-01-17) + +## Bug Fixes +- **$compile:** fix ng-prop-* with undefined values + ([772440](https://github.com/angular/angular.js/commit/772440cdaf9a9bfa40de1675e20a5f0e356089ed), + [#16797](https://github.com/angular/angular.js/issues/16797), + [#16798](https://github.com/angular/angular.js/issues/16798)) +- **compile:** properly handle false value for boolean attrs with jQuery + ([27486b](https://github.com/angular/angular.js/commit/27486bd15e70946ece2ba713e4e8654b7f9bddad), + [#16778](https://github.com/angular/angular.js/issues/16778), + [#16779](https://github.com/angular/angular.js/issues/16779)) +- **ngRepeat:** + - fix reference to last collection value remaining across linkages + ([cf919a](https://github.com/angular/angular.js/commit/cf919a6fb7fc655f3fa37a74899a797ea5b8073e)) + - fix trackBy function being invoked with incorrect scope + ([d4d103](https://github.com/angular/angular.js/commit/d4d1031bcd9b30ae6a58bd60a79bcc9d20f0f2b7), + [#16776](https://github.com/angular/angular.js/issues/16776), + [#16777](https://github.com/angular/angular.js/issues/16777)) +- **aria/ngClick:** check if element is `contenteditable` before blocking spacebar + ([289374](https://github.com/angular/angular.js/commit/289374a43c1b2fd715ddf7455db225b17afebbaf), + [#16762](https://github.com/angular/angular.js/issues/16762)) +- **input:** prevent browsers from autofilling hidden inputs + ([7cbb10](https://github.com/angular/angular.js/commit/7cbb1044fcb3576cdad791bd22ebea3dfd533ff8)) +- **Angular:** add workaround for Safari / Webdriver problem + ([eb49f6](https://github.com/angular/angular.js/commit/eb49f6b7555cfd7ab03fd35581adb6b4bd49044e)) +- **$browser:** normalize inputted URLs + ([2f72a6](https://github.com/angular/angular.js/commit/2f72a69ded53a122afad3ec28d91f9bd2f41eb4f), + [#16606](https://github.com/angular/angular.js/issues/16606)) +- **interpolate:** do not create directives for constant media URL attributes + ([90a41d](https://github.com/angular/angular.js/commit/90a41d415c83abdbf28317f49df0fd0a7e07db86), + [#16734](https://github.com/angular/angular.js/issues/16734)) +- **$q:** allow third-party promise libraries + ([eefaa7](https://github.com/angular/angular.js/commit/eefaa76a90dbef08fdc7d734a205cc2de50d9f91), + [#16164](https://github.com/angular/angular.js/issues/16164), + [#16471](https://github.com/angular/angular.js/issues/16471)) +- **urlUtils:** make IPv6 URL's hostname wrapped in square brackets in IE/Edge + ([0e1bd7](https://github.com/angular/angular.js/commit/0e1bd7822e61822a48b8fd7ba5913a8702e6dabf), + [#16692](https://github.com/angular/angular.js/issues/16692), + [#16715](https://github.com/angular/angular.js/issues/16715)) +- **ngAnimateSwap:** make it compatible with `ngIf` on the same element + ([b27080](https://github.com/angular/angular.js/commit/b27080d52546409fb4e483f212f03616e2ca8037), + [#16616](https://github.com/angular/angular.js/issues/16616), + [#16729](https://github.com/angular/angular.js/issues/16729)) +- **ngMock:** make matchLatestDefinitionEnabled work + ([3cdffc](https://github.com/angular/angular.js/commit/3cdffcecbae71189b4db69b57fadda6608a23b61), + [#16702](https://github.com/angular/angular.js/issues/16702)) +- **ngStyle:** skip setting empty value when new style has the property + ([d6098e](https://github.com/angular/angular.js/commit/d6098eeb1c9510d599e9bd3cfdba7dd21e7a55a5), + [#16709](https://github.com/angular/angular.js/issues/16709)) + +## Performance Improvements +- **input:** prevent multiple validations on initialization + ([692622](https://github.com/angular/angular.js/commit/69262239632027b373258e75c670b89132ad9edb), + [#14691](https://github.com/angular/angular.js/issues/14691), + [#16760](https://github.com/angular/angular.js/issues/16760)) + + + + +# 1.7.5 anti-prettification (2018-10-04) + +## Bug Fixes +- **ngClass:** do not break on invalid values + ([f3a565](https://github.com/angular/angular.js/commit/f3a565872d802c94bb213944791b11b483d52f73), + [#16697](https://github.com/angular/angular.js/issues/16697), + [#16699](https://github.com/angular/angular.js/issues/16699)) + + + +# 1.7.4 interstellar-exploration (2018-09-07) + +## Bug Fixes +- **ngAria.ngClick:** prevent default event on space/enter only for non-interactive elements + ([61b335](https://github.com/angular/angular.js/commit/61b33543ff8e7f32464dec98a46bf0a35e9b03a4), + [#16664](https://github.com/angular/angular.js/issues/16664), + [#16680](https://github.com/angular/angular.js/issues/16680)) +- **ngAnimate:** remove the "prepare" classes with multiple structural animations + ([3105b2](https://github.com/angular/angular.js/commit/3105b2c26a71594c4e7904efc18f4b2e9da25b1b), + [#16681](https://github.com/angular/angular.js/issues/16681), + [#16677](https://github.com/angular/angular.js/issues/16677)) +- **$route:** correctly extract path params if the path contains a question mark or a hash + ([2ceeb7](https://github.com/angular/angular.js/commit/2ceeb739f35e01fcebcabac4beeeb7684ae9f86d)) +- **ngHref:** allow numbers and other objects in interpolation + ([30084c](https://github.com/angular/angular.js/commit/30084c13699c814ff6703d7aa2d3947a9b2f7067), + [#16652](https://github.com/angular/angular.js/issues/16652), + [#16626](https://github.com/angular/angular.js/issues/16626)) +- **select:** allow to select first option with value `undefined` + ([668a33](https://github.com/angular/angular.js/commit/668a33da3439f17e61dfa8f6d9b114ebde8c9d87), + [#16653](https://github.com/angular/angular.js/issues/16653), + [#16656](https://github.com/angular/angular.js/issues/16656)) + + + +# 1.7.3 eventful-proposal (2018-08-03) + +## Bug Fixes +- **$location:** + - fix infinite recursion/digest on URLs with special characters + ([e68697](https://github.com/angular/angular.js/commit/e68697e2e30695f509e6c2c1e43c2c02b7af41f0), + [#16592](https://github.com/angular/angular.js/issues/16592), + [#16611](https://github.com/angular/angular.js/issues/16611)) + - avoid unnecessary `$locationChange*` events due to empty hash + ([1144b1](https://github.com/angular/angular.js/commit/1144b1eccb886ea0e4a80bcb07d38a305c3263b4), + [#16632](https://github.com/angular/angular.js/issues/16632), + [#16636](https://github.com/angular/angular.js/issues/16636)) +- **ngMock.$httpBackend:** + - pass failed HTTP expectations to `$exceptionHandler` + ([4adbf8](https://github.com/angular/angular.js/commit/4adbf82a84a564a8d3f0982c17a64c6163200bcd), + [#16644](https://github.com/angular/angular.js/issues/16644)) + - correctly ignore query params in {expect,when}Route + ([be417f](https://github.com/angular/angular.js/commit/be417f28549e184fbc3c7f74251ac21fca965ae8), + [#14173](https://github.com/angular/angular.js/issues/14173), + [#16589](https://github.com/angular/angular.js/issues/16589)) +- **Angular:** add workaround for Safari / Webdriver problem + ([0a1db2](https://github.com/angular/angular.js/commit/0a1db2ad5f8da6902b1711a738ae4177ce9685fa), + [#16645](https://github.com/angular/angular.js/issues/16645)) +- **$animate:** avoid memory leak with `$animate.enabled(element, enabled)` + ([4bd424](https://github.com/angular/angular.js/commit/4bd424690612885ca06028e9b27de585edc3d3c3), + [#16649](https://github.com/angular/angular.js/issues/16649)) +- **$compile:** + - use correct parent element when requiring on html element + ([05ac70](https://github.com/angular/angular.js/commit/05ac702bc7edae5f89c363ea661774910735ea8b), + [#16535](https://github.com/angular/angular.js/issues/16535), + [#16647](https://github.com/angular/angular.js/issues/16647)) + - work around Firefox `DocumentFragment` bug + ([10973c](https://github.com/angular/angular.js/commit/10973c3366676ac8e5b2728b1e006cdef4ea197e), + [#16607](https://github.com/angular/angular.js/issues/16607), + [#16615](https://github.com/angular/angular.js/issues/16615)) +- **ngEventDirs:** + - pass error in handler to $exceptionHandler when event was triggered in a digest + ([688211](https://github.com/angular/angular.js/commit/6882113bc194fb10081db9bab3dd7d69dd59f311)) + - don't wrap the event handler in $apply if already in $digest + ([535ee3](https://github.com/angular/angular.js/commit/535ee32a0b4881c9fd526fb5e0ffc10919ba1800), + [#14673](https://github.com/angular/angular.js/issues/14673), + [#14674](https://github.com/angular/angular.js/issues/14674)) +- **angular.element:** do not break on `cleanData()` if `_data()` returns undefined + ([7cf4a2](https://github.com/angular/angular.js/commit/7cf4a2933cb017e45b0c97b0a836cbbd905ee31a), + [#16641](https://github.com/angular/angular.js/issues/16641), + [#16642](https://github.com/angular/angular.js/issues/16642)) +- **ngAria:** do not scroll when pressing spacebar on custom buttons + ([3a517c](https://github.com/angular/angular.js/commit/3a517c25f677294a7a9eca1660654a3edcc9e103), + [#14665](https://github.com/angular/angular.js/issues/14665), + [#16604](https://github.com/angular/angular.js/issues/16604)) + + +## New Features +- **$compile:** add support for arbitrary DOM property and event bindings + ([a5914c](https://github.com/angular/angular.js/commit/a5914c94a8fa5b1eceeab9e4e6849cbf467bc26d), + [#16428](https://github.com/angular/angular.js/issues/16428), + [#16235](https://github.com/angular/angular.js/issues/16235), + [#16614](https://github.com/angular/angular.js/issues/16614)) +- **ngMock:** add `$flushPendingTasks()` and `$verifyNoPendingTasks()` + ([6f7674](https://github.com/angular/angular.js/commit/6f7674a7d063d434205f75f5b861f167e8125999), + [#14336](https://github.com/angular/angular.js/issues/14336)) +- **core:** implement more granular pending task tracking + ([17b139](https://github.com/angular/angular.js/commit/17b139f107e5471a9351af638093a8e13a69e42a)) +- **$animate:** add option data to event callbacks + ([fc64e6](https://github.com/angular/angular.js/commit/fc64e6807642512b567deb52b497bd2bff570a1f), + [#12697](https://github.com/angular/angular.js/issues/12697), + [#13059](https://github.com/angular/angular.js/issues/13059)) +- **form.FormController:** add $getControls() + ([c9d1e6](https://github.com/angular/angular.js/commit/c9d1e690aa597283373b78e646676fa8f1ba1b4d), + [#16601](https://github.com/angular/angular.js/issues/16601), + [#14749](https://github.com/angular/angular.js/issues/14749), + [#14517](https://github.com/angular/angular.js/issues/14517), + [#13202](https://github.com/angular/angular.js/issues/13202)) +- **ngModelOptions:** add `timeStripZeroSeconds` and `timeSecondsFormat` + ([b68221](https://github.com/angular/angular.js/commit/b682213d72d65c996a6a31ea57b79d4c4f4e3c98), + [#10721](https://github.com/angular/angular.js/issues/10721), + [#16510](https://github.com/angular/angular.js/issues/16510), + [#16584](https://github.com/angular/angular.js/issues/16584)) + + +## Performance Improvements +- **ngAnimate:** avoid repeated calls to addClass/removeClass when animation has no duration + ([093635](https://github.com/angular/angular.js/commit/0936353e9a03f072bc3c4056888fd154a96530ef), + [#14165](https://github.com/angular/angular.js/issues/14165), + [#14166](https://github.com/angular/angular.js/issues/14166), + [#16613](https://github.com/angular/angular.js/issues/16613)) + + + +# 1.7.2 extreme-compatiplication (2018-06-12) + +In the previous release, we removed a private, undocumented API that was no longer used by +AngularJS. It turned out that several popular UI libraries (such as +[AngularJS Material](https://material.angularjs.org/), +[UI Bootstrap](https://angular-ui.github.io/bootstrap/), +[ngDialog](http://likeastore.github.io/ngDialog/) and probably others) relied on that API. + +In order to avoid unnecessary pain for developers, this release reverts the removal of the private +API and restores compatibility of the aforementioned libraries with the latest AngularJS. + +## Reverts +- **$compile:** remove `preAssignBindingsEnabled` leftovers + ([2da495](https://github.com/angular/angular.js/commit/2da49504065e9e2b71a7a5622e45118d8abbe87e), + [#16580](https://github.com/angular/angular.js/pull/16580), + [a81232](https://github.com/angular/angular.js/commit/a812327acda8bc890a4c4e809f0debb761c29625), + [#16595](https://github.com/angular/angular.js/pull/16595)) + + + +# 1.7.1 momentum-defiance (2018-06-08) + + +## Bug Fixes +- **$compile:** support transcluding multi-element directives + ([789db8](https://github.com/angular/angular.js/commit/789db83a8ae0e2db5db13289b2c29e56093d967a), + [#15554](https://github.com/angular/angular.js/issues/15554), + [#15555](https://github.com/angular/angular.js/issues/15555)) +- **ngModel:** do not throw if view value changes on destroyed scope + ([2b6c98](https://github.com/angular/angular.js/commit/2b6c9867369fd3ef1ddb687af1153478ab62ee1b), + [#16583](https://github.com/angular/angular.js/issues/16583), + [#16585](https://github.com/angular/angular.js/issues/16585)) + + +## New Features +- **$compile:** add one-way collection bindings + ([f9d1ca](https://github.com/angular/angular.js/commit/f9d1ca20c38f065f15769fbe23aee5314cb58bd4), + [#14039](https://github.com/angular/angular.js/issues/14039), + [#16553](https://github.com/angular/angular.js/issues/16553), + [#15874](https://github.com/angular/angular.js/issues/15874)) +- **ngRef:** add directive to publish controller, or element into scope + ([bf841d](https://github.com/angular/angular.js/commit/bf841d35120bf3c4655fde46af4105c85a0f1cdc), + [#16511](https://github.com/angular/angular.js/issues/16511)) +- **errorHandlingConfig:** add option to exclude error params from url + ([3d6c45](https://github.com/angular/angular.js/commit/3d6c45d76e30b1b3c4eb9672cf4a93e5251c06b3), + [#14744](https://github.com/angular/angular.js/issues/14744), + [#15707](https://github.com/angular/angular.js/issues/15707), + [#16283](https://github.com/angular/angular.js/issues/16283), + [#16299](https://github.com/angular/angular.js/issues/16299), + [#16591](https://github.com/angular/angular.js/issues/16591)) +- **ngAria:** add support for ignoring a specific element + ([7d9d38](https://github.com/angular/angular.js/commit/7d9d387195292cb5e04984602b752d31853cfea6), + [#14602](https://github.com/angular/angular.js/issues/14602), + [#14672](https://github.com/angular/angular.js/issues/14672), + [#14833](https://github.com/angular/angular.js/issues/14833)) +- **ngCookies:** support samesite option + ([10a229](https://github.com/angular/angular.js/commit/10a229ce1befdeaf6295d1635dc11391c252a91a), + [#16543](https://github.com/angular/angular.js/issues/16543), + [#16544](https://github.com/angular/angular.js/issues/16544)) +- **ngMessages:** add support for default message + ([a8c263](https://github.com/angular/angular.js/commit/a8c263c1947cc85ee60b4732f7e4bcdc7ba463e8), + [#12008](https://github.com/angular/angular.js/issues/12008), + [#12213](https://github.com/angular/angular.js/issues/12213), + [#16587](https://github.com/angular/angular.js/issues/16587)) +- **ngMock, ngMockE2E:** add option to match latest definition for `$httpBackend` request + ([773f39](https://github.com/angular/angular.js/commit/773f39c9345479f5f8b6321236ce6ad96f77aa92), + [#16251](https://github.com/angular/angular.js/issues/16251), + [#11637](https://github.com/angular/angular.js/issues/11637), + [#16560](https://github.com/angular/angular.js/issues/16560)) +- **$route:** add support for the `reloadOnUrl` configuration option + ([f4f571](https://github.com/angular/angular.js/commit/f4f571efdf86d6acbcd5c6b1de66b4b33a259125), + [#7925](https://github.com/angular/angular.js/issues/7925), + [#15002](https://github.com/angular/angular.js/issues/15002)) + + + +# 1.7.0 nonexistent-physiology (2018-05-11) + +**Here are the full changes for the release of 1.7.0 that are not already released in the 1.6.x branch, +which includes commits from 1.7.0-rc.0 and commits from 1.7.0 directly.** + +1.7.0 is the last scheduled release of AngularJS that includes breaking changes. 1.7.x patch +releases will continue to receive bug fixes and non-breaking features until AngularJS enters Long +Term Support mode (LTS) on July 1st 2018. + +## Bug Fixes +- **input:** + - listen on "change" instead of "click" for radio/checkbox ngModels + ([656c8f](https://github.com/angular/angular.js/commit/656c8fa8f23b1277cc5c214c4d0237f3393afa1e), + [#4516](https://github.com/angular/angular.js/issues/4516), + [#14667](https://github.com/angular/angular.js/issues/14667), + [#14685](https://github.com/angular/angular.js/issues/14685)) +- **input\[number\]:** validate min/max against viewValue + ([aa3f95](https://github.com/angular/angular.js/commit/aa3f951330ec7b10b43ea884d9b5754e296770ec), + [#12761](https://github.com/angular/angular.js/issues/12761), + [#16325](https://github.com/angular/angular.js/issues/16325)) +- **input\[date\]:** correctly parse 2-digit years + ([627180](https://github.com/angular/angular.js/commit/627180fb71b92048d5b9ca2606b9eff1fd99387e), + [#16537](https://github.com/angular/angular.js/issues/16537), + [#16539](https://github.com/angular/angular.js/issues/16539)) +- **jqLite:** make removeData() not remove event handlers + ([b7d396](https://github.com/angular/angular.js/commit/b7d396b8b6e8f27a1f4556d58fc903321e8d532a), + [#15869](https://github.com/angular/angular.js/issues/15869), + [#16512](https://github.com/angular/angular.js/issues/16512)) +- **$compile:** + - remove the preAssignBindingsEnabled flag + ([38f8c9](https://github.com/angular/angular.js/commit/38f8c97af74649ce224b6dd45f433cc665acfbfb), + [#15782](https://github.com/angular/angular.js/issues/15782)) + - add `base[href]` to the list of RESOURCE_URL context attributes + ([1cf728](https://github.com/angular/angular.js/commit/1cf728e209a9e0016068fac2769827e8f747760e), + [#15597](https://github.com/angular/angular.js/issues/15597)) +- **$interval:** throw when trying to cancel non-$interval promise + ([a8bef9](https://github.com/angular/angular.js/commit/a8bef95127775d83d80daa4617c33227c4b443d4), + [#16424](https://github.com/angular/angular.js/issues/16424), + [#16476](https://github.com/angular/angular.js/issues/16476)) +- **$timeout:** throw when trying to cancel non-$timeout promise + ([336525](https://github.com/angular/angular.js/commit/3365256502344970f86355d3ace1cb4251ae9828), + [#16424](https://github.com/angular/angular.js/issues/16424), + [#16476](https://github.com/angular/angular.js/issues/16476)) +- **$cookies:** remove the deprecated $cookieStore factory + ([73c646](https://github.com/angular/angular.js/commit/73c6467f1468353215dc689c019ed83aa4993c77), + [#16465](https://github.com/angular/angular.js/issues/16465)) +- **$resource:** fix interceptors and success/error callbacks + ([ea0585](https://github.com/angular/angular.js/commit/ea0585773bb93fd891576e2271254a17e15f1ddd), + [#6731](https://github.com/angular/angular.js/issues/6731), + [#9334](https://github.com/angular/angular.js/issues/9334), + [#6865](https://github.com/angular/angular.js/issues/6865), + [#16446](https://github.com/angular/angular.js/issues/16446)) +- **$templateRequest:** + - give tpload error the correct namespace + ([c617d6](https://github.com/angular/angular.js/commit/c617d6dceee5b000bfceda44ced22fc16b48b18b)) + - always return the template that is stored in the cache + ([fb0099](https://github.com/angular/angular.js/commit/fb00991460cf69ae8bc7f1f826363d09c73c0d5e), + [#16225](https://github.com/angular/angular.js/issues/16225)) +- **$animate:** let cancel() reject the runner promise + ([16b82c](https://github.com/angular/angular.js/commit/16b82c6afe0ab916fef1d6ca78053b00bf5ada83), + [#14204](https://github.com/angular/angular.js/issues/14204), + [#16373](https://github.com/angular/angular.js/issues/16373)) +- **ngTouch:** + - deprecate the module and its contents + ([67f54b](https://github.com/angular/angular.js/commit/67f54b660038de2b4346b3e76d66a8dc8ccb1f9b), + [#16427](https://github.com/angular/angular.js/issues/16427), + [#16431](https://github.com/angular/angular.js/issues/16431)) + - remove ngClick override, `$touchProvider`, and `$touch` + ([11d9ad](https://github.com/angular/angular.js/commit/11d9ad1eb25eaf5967195e424108207427835d50), + [#15761](https://github.com/angular/angular.js/issues/15761), + [#15755](https://github.com/angular/angular.js/issues/15755)) +- **ngScenario:** completely remove the angular scenario runner + ([0cd392](https://github.com/angular/angular.js/commit/0cd39217828b0ad53eaf731576af17d66c18ff60), + [#9405](https://github.com/angular/angular.js/issues/9405)) +- **form:** set $submitted to true on child forms when parent is submitted + ([223de5](https://github.com/angular/angular.js/commit/223de59e988dc0cc8b4ec3a045b7c0735eba1c77), + [#10071](https://github.com/angular/angular.js/issues/10071)) +- **$rootScope:** + - provide correct value of one-time bindings in watchGroup + ([c2b8fa](https://github.com/angular/angular.js/commit/c2b8fab0a480204374d561d6b9b3d47347ac5570)) + - don't allow explicit digest calls to affect $evalAsync + ([02c046](https://github.com/angular/angular.js/commit/02c04690da16a9bef55694f5db0b8368dc0125c9), + [#15127](https://github.com/angular/angular.js/issues/15127), + [#15494](https://github.com/angular/angular.js/issues/15494)) +- **ngAria:** do not set aria attributes on input[type="hidden"] + ([6d5ef3](https://github.com/angular/angular.js/commit/6d5ef34fc6a974cde73157ba94f9706723dd8f5b), + [#15113](https://github.com/angular/angular.js/issues/15113), + [#16367](https://github.com/angular/angular.js/issues/16367)) +- **ngModel, input:** improve handling of built-in named parsers + ([74b04c](https://github.com/angular/angular.js/commit/74b04c9403af4fc7df5b6420f22c9f45a3e84140), + [#14292](https://github.com/angular/angular.js/issues/14292), + [#10076](https://github.com/angular/angular.js/issues/10076), + [#16347](https://github.com/angular/angular.js/issues/16347)) +- **$httpParamSerializerJQLike:** + - call functions as jQuery does + ([a784fa](https://github.com/angular/angular.js/commit/a784fab605d825f1158c6292b3c42f8c4a502fdf), + [#16138](https://github.com/angular/angular.js/issues/16138), + [#16139](https://github.com/angular/angular.js/issues/16139)) + - follow jQuery for `null` and `undefined` + ([301fdd](https://github.com/angular/angular.js/commit/301fdda648680d89ccab607c413a7ddede7b0165)) +- **$parse:** + - do not pass scope/locals to interceptors of one-time bindings + ([87a586](https://github.com/angular/angular.js/commit/87a586eb9a23cfd0d0bb681cc778b4b8e5c8451d)) + - always pass the intercepted value to watchers + ([2ee503](https://github.com/angular/angular.js/commit/2ee5033967d5f87a516bad137686b0592e25d26b), + [#16021](https://github.com/angular/angular.js/issues/16021)) + - respect the interceptor.$stateful flag + ([de7403](https://github.com/angular/angular.js/commit/de74034ddf6f92505ccdb61be413a6df2c723f87)) +- **Angular:** remove `angular.lowercase` and `angular.uppercase` + ([1daa4f](https://github.com/angular/angular.js/commit/1daa4f2231a89ee88345689f001805ffffa9e7de), + [#15445](https://github.com/angular/angular.js/issues/15445)) +- **$controller:** remove instantiating controllers defined on window + ([e269c1](https://github.com/angular/angular.js/commit/e269c14425a3209040f65c022658770e00a36f16), + [#15349](https://github.com/angular/angular.js/issues/15349), + [#15762](https://github.com/angular/angular.js/issues/15762)) + + +## New Features +- **angular.isArray:** support Array subclasses in `angular.isArray()` + ([e3ece2](https://github.com/angular/angular.js/commit/e3ece2fad9e1e6d47b5f06815ff186d7e6f44948), + [#15533](https://github.com/angular/angular.js/issues/15533), + [#15541](https://github.com/angular/angular.js/issues/15541)) +- **$sce:** handle URL sanitization through the `$sce` service + ([1e9ead](https://github.com/angular/angular.js/commit/1e9eadcd72dbbd5c67dae8328a63e535cfa91ff9)) +- **orderBy:** consider `null` and `undefined` greater than other values + ([1d8046](https://github.com/angular/angular.js/commit/1d804645f7656d592c90216a0355b4948807f6b8), + [#15294](https://github.com/angular/angular.js/issues/15294), + [#16376](https://github.com/angular/angular.js/issues/16376)) +- **$resource:** add support for `request` and `requestError` interceptors (#15674) + ([240a3d](https://github.com/angular/angular.js/commit/240a3ddbf12a9bb79754031be95dae4b6bd2dded), + [#5146](https://github.com/angular/angular.js/issues/5146)) +- **ngModelOptions:** add debounce catch-all + allow debouncing 'default' only + ([55ba44](https://github.com/angular/angular.js/commit/55ba44913e02650b56410aa9ab5eeea5d3492b68), + [#15411](https://github.com/angular/angular.js/issues/15411), + [#16335](https://github.com/angular/angular.js/issues/16335)) +- **$compile:** lower the `xlink:href` security context for SVG's `a` and `image` elements + ([6ccbfa](https://github.com/angular/angular.js/commit/6ccbfa65d60a3dc396d0cf6da21b993ad74653fd), + [#15736](https://github.com/angular/angular.js/issues/15736)) + + +## Performance Improvements +- **$rootScope:** allow $watchCollection use of expression input watching + ([97b00c](https://github.com/angular/angular.js/commit/97b00ca497676aaff8a803762a9f8c7ff4aa24dd)) +- **ngStyle:** use $watchCollection + ([15bbd3](https://github.com/angular/angular.js/commit/15bbd3e18cd89b91f7206a06c73d40e54a8a48a0), + [#15947](https://github.com/angular/angular.js/issues/15947)) +- **$compile:** do not use deepWatch in literal one-way bindings + ([fd4f01](https://github.com/angular/angular.js/commit/fd4f0111188b62773b99ab6eab38b4d2b5d8d727), + [#15301](https://github.com/angular/angular.js/issues/15301)) + + + + +## Breaking Changes + +### **jqLite** due to: + - **[b7d396](https://github.com/angular/angular.js/commit/b7d396b8b6e8f27a1f4556d58fc903321e8d532a)**: make removeData() not remove event handlers + +Before this commit `removeData()` invoked on an element removed its event +handlers as well. If you want to trigger a full cleanup of an element, change: + +```js +elem.removeData(); +``` + +to: + +```js +angular.element.cleanData(elem); +``` + +In most cases, though, cleaning up after an element is supposed to be done +only when it's removed from the DOM as well; in such cases the following: + +```js +elem.remove(); +``` + +will remove event handlers as well. + +### **$cookies** due to: + - **[73c646](https://github.com/angular/angular.js/commit/73c6467f1468353215dc689c019ed83aa4993c77)**: remove the deprecated $cookieStore factory + +The $cookieStore has been removed. Migrate to the $cookies service. Note that +for object values you need to use the `putObject` & `getObject` methods as +`get`/`put` will not correctly save/retrieve them. + +Before: +```js +$cookieStore.put('name', {key: 'value'}); +$cookieStore.get('name'); // {key: 'value'} +$cookieStore.remove('name'); +``` + +After: +```js +$cookies.putObject('name', {key: 'value'}); +$cookies.getObject('name'); // {key: 'value'} +$cookies.remove('name'); +``` + +### **$resource** due to: + - **[ea0585](https://github.com/angular/angular.js/commit/ea0585773bb93fd891576e2271254a17e15f1ddd)**: fix interceptors and success/error callbacks + +If you are not using `success` or `error` callbacks with `$resource`, +your app should not be affected by this change. + +If you are using `success` or `error` callbacks (with or without +response interceptors), one (subtle) difference is that throwing an +error inside the callbacks will not propagate to the returned +`$promise`. Therefore, you should try to use the promises whenever +possible. E.g.: + +```js +// Avoid +User.query(function onSuccess(users) { throw new Error(); }). + $promise. + catch(function onError() { /* Will not be called. */ }); + +// Prefer +User.query(). + $promise. + then(function onSuccess(users) { throw new Error(); }). + catch(function onError() { /* Will be called. */ }); +``` + +Finally, if you are using `success` or `error` callbacks with response +interceptors, the callbacks will now always run _after_ the interceptors +(and wait for them to resolve in case they return a promise). +Previously, the `error` callback was called before the `responseError` +interceptor and the `success` callback was synchronously called after +the `response` interceptor. E.g.: + +```js +var User = $resource('/api/users/:id', {id: '@id'}, { + get: { + method: 'get', + interceptor: { + response: function(response) { + console.log('responseInterceptor-1'); + return $timeout(1000).then(function() { + console.log('responseInterceptor-2'); + return response.resource; + }); + }, + responseError: function(response) { + console.log('responseErrorInterceptor-1'); + return $timeout(1000).then(function() { + console.log('responseErrorInterceptor-2'); + return $q.reject('Ooops!'); + }); + } + } + } +}); +var onSuccess = function(value) { console.log('successCallback', value); }; +var onError = function(error) { console.log('errorCallback', error); }; + +// Assuming the following call is successful... +User.get({id: 1}, onSuccess, onError); + // Old behavior: + // responseInterceptor-1 + // successCallback, {/* Promise object */} + // responseInterceptor-2 + // New behavior: + // responseInterceptor-1 + // responseInterceptor-2 + // successCallback, {/* User object */} + +// Assuming the following call returns an error... +User.get({id: 2}, onSuccess, onError); + // Old behavior: + // errorCallback, {/* Response object */} + // responseErrorInterceptor-1 + // responseErrorInterceptor-2 + // New behavior: + // responseErrorInterceptor-1 + // responseErrorInterceptor-2 + // errorCallback, Ooops! +``` + + - **[240a3d](https://github.com/angular/angular.js/commit/240a3ddbf12a9bb79754031be95dae4b6bd2dded)**: add support for `request` and `requestError` interceptors (#15674) + +Previously, calling a `$resource` method would synchronously call +`$http`. Now, it will be called asynchronously (regardless if a +`request`/`requestError` interceptor has been defined. + +This is not expected to affect applications at runtime, since the +overall operation is asynchronous already, but may affect assertions in +tests. For example, if you want to assert that `$http` has been called +with specific arguments as a result of a `$resource` call, you now need +to run a `$digest` first, to ensure the (possibly empty) request +interceptor promise has been resolved. + +Before: +```js +it('...', function() { + $httpBackend.expectGET('/api/things').respond(...); + var Things = $resource('/api/things'); + Things.query(); + + expect($http).toHaveBeenCalledWith(...); +}); +``` + +After: +```js +it('...', function() { + $httpBackend.expectGET('/api/things').respond(...); + var Things = $resource('/api/things'); + Things.query(); + $rootScope.$digest(); + + expect($http).toHaveBeenCalledWith(...); +}); +``` + +### **$templateRequest**: + - due to **[c617d6](https://github.com/angular/angular.js/commit/c617d6dceee5b000bfceda44ced22fc16b48b18b)**: give tpload error the correct namespace + +Previously the `tpload` error was namespaced to `$compile`. If you have +code that matches errors of the form `[$compile:tpload]` it will no +longer run. You should change the code to match +`[$templateRequest:tpload]`. + + - due to **([fb0099](https://github.com/angular/angular.js/commit/fb00991460cf69ae8bc7f1f826363d09c73c0d5e)**: always return the template that is stored in the cache + +The service now returns the result of `$templateCache.put()` when making a server request to the +template. Previously it would return the content of the response directly. +This now means if you are decorating `$templateCache.put()` to manipulate the template, you will +now get this manipulated result also on the first `$templateRequest` rather than only on subsequent +calls (when the template is retrived from the cache). +In practice this should not affect any apps, as it is unlikely that they rely on the template being +different in the first and subsequent calls. + +### **$animate** due to: + - **[16b82c](https://github.com/angular/angular.js/commit/16b82c6afe0ab916fef1d6ca78053b00bf5ada83)**: let cancel() reject the runner promise + +$animate.cancel(runner) now rejects the underlying +promise and calls the catch() handler on the runner +returned by $animate functions (enter, leave, move, +addClass, removeClass, setClass, animate). +Previously it would resolve the promise as if the animation +had ended successfully. + +Example: + +```js +var runner = $animate.addClass('red'); +runner.then(function() { console.log('success')}); +runner.catch(function() { console.log('cancelled')}); + +runner.cancel(); +``` + +Pre-1.7.0, this logs 'success', 1.7.0 and later it logs 'cancelled'. +To migrate, add a catch() handler to your animation runners. + +### **angular.isArray** due to: + - **[e3ece2](https://github.com/angular/angular.js/commit/e3ece2fad9e1e6d47b5f06815ff186d7e6f44948)**: support Array subclasses in `angular.isArray()` + +Previously, `angular.isArray()` was an alias for `Array.isArray()`. +Therefore, objects that prototypally inherit from `Array` where not +considered arrays. Now such objects are considered arrays too. + +This change affects several other methods that use `angular.isArray()` +under the hood, such as `angular.copy()`, `angular.equals()`, +`angular.forEach()`, and `angular.merge()`. + +This in turn affects how dirty checking treats objects that prototypally +inherit from `Array` (e.g. MobX observable arrays). AngularJS will now +be able to handle these objects better when copying or watching. + +### **$sce** : + - due to **[1e9ead](https://github.com/angular/angular.js/commit/1e9eadcd72dbbd5c67dae8328a63e535cfa91ff9)**: handle URL sanitization through the `$sce` service + +If you use `attrs.$set` for URL attributes (a[href] and img[src]) there will no +longer be any automated sanitization of the value. This is in line with other +programmatic operations, such as writing to the innerHTML of an element. + +If you are programmatically writing URL values to attributes from untrusted +input then you must sanitize it yourself. You could write your own sanitizer or copy +the private `$$sanitizeUri` service. + +Note that values that have been passed through the `$interpolate` service within the +`URL` or `MEDIA_URL` will have already been sanitized, so you would not need to sanitize +these values again. + + - due to **[1e9ead](https://github.com/angular/angular.js/commit/1e9eadcd72dbbd5c67dae8328a63e535cfa91ff9)**: handle URL sanitization through the `$sce` service + +binding `trustAs()` and the short versions (`trustAsResourceUrl()` et al.) to +`ngSrc`, `ngSrcset`, and `ngHref` will now raise an infinite digest error: + +```js + $scope.imgThumbFn = function(id) { + return $sce.trustAsResourceUrl(someService.someUrl(id)); + }; +``` + +```html + +``` +This is because the `$interpolate` service is now responsible for sanitizing +the attribute value, and its watcher receives a new object from `trustAs()` +on every digest. +To migrate, compute the trusted value only when the input value changes: + +```js + $scope.$watch('imgId', function(id) { + $scope.imgThumb = $sce.trustAsResourceUrl(someService.someUrl(id)); + }); +``` + +```html + +``` + +### **orderBy** due to: + - **[1d8046](https://github.com/angular/angular.js/commit/1d804645f7656d592c90216a0355b4948807f6b8)**: consider `null` and `undefined` greater than other values + +When using `orderBy` to sort arrays containing `null` values, the `null` values +will be considered "greater than" all other values, except for `undefined`. +Previously, they were sorted as strings. This will result in different (but more +intuitive) sorting order. + +Before: +```js +orderByFilter(['a', undefined, 'o', null, 'z']); +//--> 'a', null, 'o', 'z', undefined +``` + +After: +```js +orderByFilter(['a', undefined, 'o', null, 'z']); +//--> 'a', 'o', 'z', null, undefined +``` + +### **ngScenario** due to: + - **[0cd392](https://github.com/angular/angular.js/commit/0cd39217828b0ad53eaf731576af17d66c18ff60)**: completely remove the angular scenario runner + +The angular scenario runner end-to-end test framework has been +removed from the project and will no longer be available on npm +or bower starting with 1.7.0. +It was deprecated and removed from the documentation in 2014. +Applications that still use it should migrate to +[Protractor](http://www.protractortest.org). +Technically, it should also be possible to continue using an +older version of the scenario runner, as the underlying APIs have +not changed. However, we do not guarantee future compatibility. + +### **form** due to: + - **[223de5](https://github.com/angular/angular.js/commit/223de59e988dc0cc8b4ec3a045b7c0735eba1c77)**: set $submitted to true on child forms when parent is submitted + +Forms will now set $submitted on child forms when they are submitted. +For example: +``` +
+ + + + +
+``` + +Submitting this form will set $submitted on "parentform" and "childform". +Previously, it was only set on "parentform". + +This change was introduced because mixing form and ngForm does not create +logically separate forms, but rather something like input groups. +Therefore, child forms should inherit the submission state from their parent form. + +### **ngAria** due to: + - **[6d5ef3](https://github.com/angular/angular.js/commit/6d5ef34fc6a974cde73157ba94f9706723dd8f5b)**: do not set aria attributes on input[type="hidden"] + +ngAria no longer sets aria-* attributes on input[type="hidden"] with ngModel. +This can affect apps that test for the presence of aria attributes on hidden inputs. +To migrate, remove these assertions. +In actual apps, this should not have a user-facing effect, as the previous behavior +was incorrect, and the new behavior is correct for accessibility. + +### **ngModel, input** due to: + - **[74b04c](https://github.com/angular/angular.js/commit/74b04c9403af4fc7df5b6420f22c9f45a3e84140)**: improve handling of built-in named parsers + +*Custom* parsers that fail to parse on input types "email", "url", "number", "date", "month", +"time", "datetime-local", "week", do no longer set `ngModelController.$error[inputType]`, and +the `ng-invalid-[inputType]` class. Also, custom parsers on input type "range" do no +longer set `ngModelController.$error.number` and the `ng-invalid-number` class. + +Instead, any custom parsers on these inputs set `ngModelController.$error.parse` and +`ng-invalid-parse`. This change was made to make distinguishing errors from built-in parsers +and custom parsers easier. + +### **ngModelOptions** due to: + - **[55ba44](https://github.com/angular/angular.js/commit/55ba44913e02650b56410aa9ab5eeea5d3492b68)**: add debounce catch-all + allow debouncing 'default' only + +the 'default' key in 'debounce' now only debounces the default event, i.e. the event +that is added as an update trigger by the different input directives automatically. + +Previously, it also applied to other update triggers defined in 'updateOn' that +did not have a corresponding key in the 'debounce'. + +This behavior is now supported via a special wildcard / catch-all key: '*'. + +See the following example: + +Pre-1.7: +'mouseup' is also debounced by 500 milliseconds because 'default' is applied: +``` +ng-model-options="{ + updateOn: 'default blur mouseup', + debounce: { 'default': 500, 'blur': 0 } +} +``` + +1.7: +The pre-1.7 behavior can be re-created by setting '*' as a catch-all debounce value: +``` +ng-model-options="{ + updateOn: 'default blur mouseup', + debounce: { '*': 500, 'blur': 0 } +} +``` + +In contrast, when only 'default' is used, 'blur' and 'mouseup' are not debounced: +``` +ng-model-options="{ + updateOn: 'default blur mouseup', + debounce: { 'default': 500 } +} +``` + +### **input\[number\]** due to: + - **[aa3f95](https://github.com/angular/angular.js/commit/aa3f951330ec7b10b43ea884d9b5754e296770ec)**: validate min/max against viewValue + +`input[type=number]` with `ngModel` now validates the input for the `max`/`min` restriction against +the `ngModelController.$viewValue` instead of against the `ngModelController.$modelValue`. + +This affects apps that use `$parsers` or `$formatters` to transform the input / model value. + +If you rely on the $modelValue validation, you can overwrite the `min`/`max` validator from a custom directive, as seen in the following example directive definition object: + +``` +{ + restrict: 'A', + require: 'ngModel', + link: function(scope, element, attrs, ctrl) { + var maxValidator = ctrl.$validators.max; + + ctrl.$validators.max = function(modelValue, viewValue) { + return maxValidator(modelValue, modelValue); + }; + } +} +``` + +### **input** due to: + - **[656c8f](https://github.com/angular/angular.js/commit/656c8fa8f23b1277cc5c214c4d0237f3393afa1e)**: listen on "change" instead of "click" for radio/checkbox ngModels + +`input[radio]` and `input[checkbox]` now listen to the "change" event instead of the "click" event. +Most apps should not be affected, as "change" is automatically fired by browsers after "click" +happens. + +Two scenarios might need migration: + +- Custom click events: + +Before this change, custom click event listeners on radio / checkbox would be called after the +input element and `ngModel` had been updated, unless they were specifically registered before +the built-in click handlers. +After this change, they are called before the input is updated, and can call event.preventDefault() +to prevent the input from updating. + +If an app uses a click event listener that expects ngModel to be updated when it is called, it now +needs to register a change event listener instead. + +- Triggering click events: + +Conventional trigger functions: + +The change event might not be fired when the input element is not attached to the document. This +can happen in **tests** that compile input elements and +trigger click events on them. Depending on the browser (Chrome and Safari) and the trigger method, +the change event will not be fired when the input isn't attached to the document. + +Before: + +```js + it('should update the model', inject(function($compile, $rootScope) { + var inputElm = $compile('')($rootScope); + + inputElm[0].click(); // Or different trigger mechanisms, such as jQuery.trigger() + expect($rootScope.checkbox).toBe(true); + }); +``` + +With this patch, `$rootScope.checkbox` might not be true, because the click event +hasn't triggered the change event. To make the test, work append the inputElm to the app's +`$rootElement`, and the `$rootElement` to the `$document`. + +After: + +```js + it('should update the model', inject(function($compile, $rootScope, $rootElement, $document) { + var inputElm = $compile('')($rootScope); + + $rootElement.append(inputElm); + $document.append($rootElement); + + inputElm[0].click(); // Or different trigger mechanisms, such as jQuery.trigger() + expect($rootScope.checkbox).toBe(true); + }); +``` + +`triggerHandler()`: + +If you are using this jQuery / jqLite function on the input elements, you don't have to attach +the elements to the document, but instead change the triggered event to "change". This is because +`triggerHandler(event)` only triggers the exact event when it has been added by jQuery / jqLite. + +### **ngStyle** due to: + - **[15bbd3](https://github.com/angular/angular.js/commit/15bbd3e18cd89b91f7206a06c73d40e54a8a48a0)**: use $watchCollection + +Previously the use of deep watch by ng-style would trigger styles to be +re-applied when nested state changed. Now only changes to direct +properties of the watched object will trigger changes. + +### **$compile** due to: + - **[38f8c9](https://github.com/angular/angular.js/commit/38f8c97af74649ce224b6dd45f433cc665acfbfb)**: remove the preAssignBindingsEnabled flag + +Previously, the `$compileProvider.preAssignBindingsEnabled` flag was supported. +The flag controlled whether bindings were available inside the controller +constructor or only in the `$onInit` hook. The bindings are now no longer +available in the constructor. + +To migrate your code: + +1. If you haven't invoked `$compileProvider.preAssignBindingsEnabled()` you +don't have to do anything to migrate. + +2. If you specified `$compileProvider.preAssignBindingsEnabled(false)`, you +can remove that statement - since AngularJS 1.6.0 this is the default so your +app should still work even in AngularJS 1.6 after such removal. Afterwards, +migrating to AngularJS 1.7.0 shouldn't require any further action. + +3. If you specified `$compileProvider.preAssignBindingsEnabled(true)` you need +to first migrate your code so that the flag can be flipped to `false`. The +instructions on how to do that are available in the "Migrating from 1.5 to 1.6" +guide: +https://docs.angularjs.org/guide/migration#migrating-from-1-5-to-1-6 +Afterwards, remove the `$compileProvider.preAssignBindingsEnabled(true)` +statement. + + - **[6ccbfa](https://github.com/angular/angular.js/commit/6ccbfa65d60a3dc396d0cf6da21b993ad74653fd)**: lower the `xlink:href` security context for SVG's `a` and `image` elements + +In the unlikely case that an app relied on RESOURCE_URL whitelisting for the +purpose of binding to the `xlink:href` property of SVG's `` or `` +elements and if the values do not pass the regular URL sanitization, they will +break. + +To fix this you need to ensure that the values used for binding to the affected +`xlink:href` contexts are considered safe URLs, e.g. by whitelisting them in +`$compileProvider`'s `aHrefSanitizationWhitelist` (for `` elements) or +`imgSrcSanitizationWhitelist` (for `` elements). + + - **[fd4f01](https://github.com/angular/angular.js/commit/fd4f0111188b62773b99ab6eab38b4d2b5d8d727)**: do not use deepWatch in literal one-way bindings + +Previously when a literal value was passed into a directive/component via +one-way binding it would be watched with a deep watcher. + +For example, for ``, a new instance of the array +would be passed into the directive/component (and trigger $onChanges) not +only if `a` changed but also if any sub property of `a` changed such as +`a.b` or `a.b.c.d.e` etc. + +This also means a new but equal value for `a` would NOT trigger such a +change. + +Now literal values use an input-based watch similar to other directive/component +one-way bindings. In this context inputs are the non-constant parts of the +literal. In the example above the input would be `a`. Changes are only +triggered when the inputs to the literal change. + + - **[1cf728](https://github.com/angular/angular.js/commit/1cf728e209a9e0016068fac2769827e8f747760e)**: add `base[href]` to the list of RESOURCE_URL context attributes + +Previously, `` would not require `baseUrl` to +be trusted as a RESOURCE_URL. Now, `baseUrl` will be sent to `$sce`'s +RESOURCE_URL checks. By default, it will break unless `baseUrl` is of the same +origin as the application document. + +Refer to the +[`$sce` API docs](https://code.angularjs.org/snapshot/docs/api/ng/service/$sce) +for more info on how to trust a value in a RESOURCE_URL context. + +Also, concatenation in trusted contexts is not allowed, which means that the +following won't work: ``. + +Either construct complex values in a controller (recommended): + +```js +this.baseUrl = '/something/' + this.partialPath; +``` +```html + +``` + +Or use string concatenation in the interpolation expression (not recommended +except for the simplest of cases): + +```html + +``` + +### **ngTouch** due to: + - **[11d9ad](https://github.com/angular/angular.js/commit/11d9ad1eb25eaf5967195e424108207427835d50)**: remove ngClick override, `$touchProvider`, and `$touch` + +The `ngClick` directive from the ngTouch module has been removed, and with it the +corresponding `$touchProvider` and `$touch` service. + +If you have included ngTouch v1.5.0 or higher in your application, and have not +changed the value of `$touchProvider.ngClickOverrideEnabled()`, or injected and used the `$touch` +service, then there are no migration steps for your code. Otherwise you must remove references to +the provider and service. + +The `ngClick` override directive had been deprecated and by default disabled since v1.5.0, +because of buggy behavior in edge cases, and a general trend to avoid special touch based +overrides of click events. In modern browsers, it should not be necessary to use a touch override +library: + +- Chrome, Firefox, Edge, and Safari remove the 300ms delay when + `` is set. +- Internet Explorer 10+, Edge, Safari, and Chrome remove the delay on elements that have the + `touch-action` css property is set to `manipulation`. + +You can find out more in these articles: +https://developers.google.com/web/updates/2013/12/300ms-tap-delay-gone-away +https://developer.apple.com/library/content/releasenotes/General/WhatsNewInSafari/Articles/Safari_9_1.html#//apple_ref/doc/uid/TP40014305-CH10-SW8 +https://blogs.msdn.microsoft.com/ie/2015/02/24/pointer-events-w3c-recommendation-interoperable-touch-and-removing-the-dreaded-300ms-tap-delay/ + +### **Angular** due to: + - **[1daa4f](https://github.com/angular/angular.js/commit/1daa4f2231a89ee88345689f001805ffffa9e7de)**: remove `angular.lowercase` and `angular.uppercase` + +The helper functions `angular.lowercase` `and angular.uppercase` have +been removed. + +These functions have been deprecated since 1.5.0. They are internally +used, but should not be exposed as they contain special locale handling +(for Turkish) to maintain internal consistency regardless of user-set locale. + +Developers should generally use the built-ins `toLowerCase` and `toUpperCase` +or `toLocaleLowerCase` and `toLocaleUpperCase` for special cases. + +Further, we generally discourage using the angular.x helpers in application code. + +### **$controller** due to: + - **[e269c1](https://github.com/angular/angular.js/commit/e269c14425a3209040f65c022658770e00a36f16)**: remove instantiating controllers defined on window + +The option to instantiate controllers from constructors on the global `window` object +has been removed. Likewise, the deprecated `$controllerProvider.allowGlobals()` +method that could enable this behavior, has been removed. + +This behavior had been deprecated since AngularJS v1.3.0, because polluting the global scope +is bad. To migrate, remove the call to $controllerProvider.allowGlobals() in the config, and +register your controller via the Module API or the $controllerProvider, e.g. + +``` +angular.module('myModule', []).controller('myController', function() {...}); + +angular.module('myModule', []).config(function($controllerProvider) { + $controllerProvider.register('myController', function() {...}); +}); + +``` + +### **$rootScope** due to: + - **[c2b8fa](https://github.com/angular/angular.js/commit/c2b8fab0a480204374d561d6b9b3d47347ac5570)**: provide correct value of one-time bindings in watchGroup + +Previously when using `$watchGroup` the entries in `newValues` and +`oldValues` represented the *most recent change of each entry*. + +Now the entries in `oldValues` will always equal the `newValues` of the previous +call of the listener. This means comparing the entries in `newValues` and +`oldValues` can be used to determine which individual expressions changed. + +For example `$scope.$watchGroup(['a', 'b'], fn)` would previously: + +| Action | newValue | oldValue | +|----------|------------|------------| +| (init) | [undefined, undefined] | [undefined, undefined] | +| `a=1` | [1, undefined] | [undefined, undefined] | +| `a=2` | [2, undefined] | [1, undefined] | +| `b=3` | [2, 3] | [1, undefined] | + + +Now the `oldValue` will always equal the previous `newValue`: + +| Action | newValue | oldValue | +|----------|------------|------------| +| (init) | [undefined, undefined] | [undefined, undefined] | +| `a=1` | [1, undefined] | [undefined, undefined] | +| `a=2` | [2, undefined] | [1, undefined] | +| `b=3` | [2, 3] | [2, undefined] | + +Note the last call now shows `a === 2` in the `oldValues` array. + +This also makes the `oldValue` of one-time watchers more clear. Previously +the `oldValue` of a one-time watcher would remain `undefined` forever. For +example `$scope.$watchGroup(['a', '::b'], fn)` would previously: + +| Action | newValue | oldValue | +|----------|------------|------------| +| (init) | [undefined, undefined] | [undefined, undefined] | +| `a=1` | [1, undefined] | [undefined, undefined] | +| `b=2` | [1, 2] | [undefined, undefined] | +| `a=b=3` | [3, 2] | [1, undefined] | + +Where now the `oldValue` will always equal the previous `newValue`: + +| Action | newValue | oldValue | +|----------|------------|------------| +| (init) | [undefined, undefined] | [undefined, undefined] | +| `a=1` | [1, undefined] | [undefined, undefined] | +| `b=2` | [1, 2] | [1, undefined] | +| `a=b=3` | [3, 2] | [1, 2] | + +### **$interval** due to: + - **[a8bef9](https://github.com/angular/angular.js/commit/a8bef95127775d83d80daa4617c33227c4b443d4)**: throw when trying to cancel non-$interval promise + +`$interval.cancel()` will throw an error if called with a promise that +was not generated by `$interval()`. Previously, it would silently do +nothing. + +Before: +```js +var promise = $interval(doSomething, 1000, 5).then(doSomethingElse); +$interval.cancel(promise); // No error; interval NOT canceled. +``` + +After: +```js +var promise = $interval(doSomething, 1000, 5).then(doSomethingElse); +$interval.cancel(promise); // Throws error. +``` + +Correct usage: +```js +var promise = $interval(doSomething, 1000, 5); +var newPromise = promise.then(doSomethingElse); +$interval.cancel(promise); // Interval canceled. +``` + +### **$timeout** due to: + - **[336525](https://github.com/angular/angular.js/commit/3365256502344970f86355d3ace1cb4251ae9828)**: throw when trying to cancel non-$timeout promise + +`$timeout.cancel()` will throw an error if called with a promise that +was not generated by `$timeout()`. Previously, it would silently do +nothing. + +Before: +```js +var promise = $timeout(doSomething, 1000).then(doSomethingElse); +$timeout.cancel(promise); // No error; timeout NOT canceled. +``` + +After: +```js +var promise = $timeout(doSomething, 1000).then(doSomethingElse); +$timeout.cancel(promise); // Throws error. +``` + +Correct usage: +```js +var promise = $timeout(doSomething, 1000); +var newPromise = promise.then(doSomethingElse); +$timeout.cancel(promise); // Timeout canceled. +``` + + +# 1.7.0-rc.0 maximum-overdrive (2018-04-19) + +## Bug Fixes +- **input:** + - listen on "change" instead of "click" for radio/checkbox ngModels + ([656c8f](https://github.com/angular/angular.js/commit/656c8fa8f23b1277cc5c214c4d0237f3393afa1e), + [#4516](https://github.com/angular/angular.js/issues/4516), + [#14667](https://github.com/angular/angular.js/issues/14667), + [#14685](https://github.com/angular/angular.js/issues/14685)) +- **input\[number\]:** validate min/max against viewValue + ([aa3f95](https://github.com/angular/angular.js/commit/aa3f951330ec7b10b43ea884d9b5754e296770ec), + [#12761](https://github.com/angular/angular.js/issues/12761), + [#16325](https://github.com/angular/angular.js/issues/16325)) +- **jqLite:** make removeData() not remove event handlers + ([b7d396](https://github.com/angular/angular.js/commit/b7d396b8b6e8f27a1f4556d58fc903321e8d532a), + [#15869](https://github.com/angular/angular.js/issues/15869), + [#16512](https://github.com/angular/angular.js/issues/16512)) +- **$compile:** + - remove the preAssignBindingsEnabled flag + ([38f8c9](https://github.com/angular/angular.js/commit/38f8c97af74649ce224b6dd45f433cc665acfbfb), + [#15782](https://github.com/angular/angular.js/issues/15782)) + - add `base[href]` to the list of RESOURCE_URL context attributes + ([1cf728](https://github.com/angular/angular.js/commit/1cf728e209a9e0016068fac2769827e8f747760e), + [#15597](https://github.com/angular/angular.js/issues/15597)) +- **$interval:** throw when trying to cancel non-$interval promise + ([a8bef9](https://github.com/angular/angular.js/commit/a8bef95127775d83d80daa4617c33227c4b443d4), + [#16424](https://github.com/angular/angular.js/issues/16424), + [#16476](https://github.com/angular/angular.js/issues/16476)) +- **$timeout:** throw when trying to cancel non-$timeout promise + ([336525](https://github.com/angular/angular.js/commit/3365256502344970f86355d3ace1cb4251ae9828), + [#16424](https://github.com/angular/angular.js/issues/16424), + [#16476](https://github.com/angular/angular.js/issues/16476)) +- **$cookies:** remove the deprecated $cookieStore factory + ([73c646](https://github.com/angular/angular.js/commit/73c6467f1468353215dc689c019ed83aa4993c77), + [#16465](https://github.com/angular/angular.js/issues/16465)) +- **$resource:** fix interceptors and success/error callbacks + ([ea0585](https://github.com/angular/angular.js/commit/ea0585773bb93fd891576e2271254a17e15f1ddd), + [#6731](https://github.com/angular/angular.js/issues/6731), + [#9334](https://github.com/angular/angular.js/issues/9334), + [#6865](https://github.com/angular/angular.js/issues/6865), + [#16446](https://github.com/angular/angular.js/issues/16446)) +- **$templateRequest:** + - give tpload error the correct namespace + ([c617d6](https://github.com/angular/angular.js/commit/c617d6dceee5b000bfceda44ced22fc16b48b18b)) + - always return the template that is stored in the cache + ([fb0099](https://github.com/angular/angular.js/commit/fb00991460cf69ae8bc7f1f826363d09c73c0d5e), + [#16225](https://github.com/angular/angular.js/issues/16225)) +- **$animate:** let cancel() reject the runner promise + ([16b82c](https://github.com/angular/angular.js/commit/16b82c6afe0ab916fef1d6ca78053b00bf5ada83), + [#14204](https://github.com/angular/angular.js/issues/14204), + [#16373](https://github.com/angular/angular.js/issues/16373)) +- **ngTouch:** + - deprecate the module and its contents + ([67f54b](https://github.com/angular/angular.js/commit/67f54b660038de2b4346b3e76d66a8dc8ccb1f9b), + [#16427](https://github.com/angular/angular.js/issues/16427), + [#16431](https://github.com/angular/angular.js/issues/16431)) + - remove ngClick override, `$touchProvider`, and `$touch` + ([11d9ad](https://github.com/angular/angular.js/commit/11d9ad1eb25eaf5967195e424108207427835d50), + [#15761](https://github.com/angular/angular.js/issues/15761), + [#15755](https://github.com/angular/angular.js/issues/15755)) +- **ngScenario:** completely remove the angular scenario runner + ([0cd392](https://github.com/angular/angular.js/commit/0cd39217828b0ad53eaf731576af17d66c18ff60), + [#9405](https://github.com/angular/angular.js/issues/9405)) +- **form:** set $submitted to true on child forms when parent is submitted + ([223de5](https://github.com/angular/angular.js/commit/223de59e988dc0cc8b4ec3a045b7c0735eba1c77), + [#10071](https://github.com/angular/angular.js/issues/10071)) +- **$rootScope:** + - provide correct value of one-time bindings in watchGroup + ([c2b8fa](https://github.com/angular/angular.js/commit/c2b8fab0a480204374d561d6b9b3d47347ac5570)) +- **ngAria:** do not set aria attributes on input[type="hidden"] + ([6d5ef3](https://github.com/angular/angular.js/commit/6d5ef34fc6a974cde73157ba94f9706723dd8f5b), + [#15113](https://github.com/angular/angular.js/issues/15113), + [#16367](https://github.com/angular/angular.js/issues/16367)) +- **ngModel, input:** improve handling of built-in named parsers + ([74b04c](https://github.com/angular/angular.js/commit/74b04c9403af4fc7df5b6420f22c9f45a3e84140), + [#14292](https://github.com/angular/angular.js/issues/14292), + [#10076](https://github.com/angular/angular.js/issues/10076), + [#16347](https://github.com/angular/angular.js/issues/16347)) +- **$httpParamSerializerJQLike:** + - call functions as jQuery does + ([a784fa](https://github.com/angular/angular.js/commit/a784fab605d825f1158c6292b3c42f8c4a502fdf), + [#16138](https://github.com/angular/angular.js/issues/16138), + [#16139](https://github.com/angular/angular.js/issues/16139)) + - follow jQuery for `null` and `undefined` + ([301fdd](https://github.com/angular/angular.js/commit/301fdda648680d89ccab607c413a7ddede7b0165)) +- **$parse:** + - do not pass scope/locals to interceptors of one-time bindings + ([87a586](https://github.com/angular/angular.js/commit/87a586eb9a23cfd0d0bb681cc778b4b8e5c8451d)) + - always pass the intercepted value to watchers + ([2ee503](https://github.com/angular/angular.js/commit/2ee5033967d5f87a516bad137686b0592e25d26b), + [#16021](https://github.com/angular/angular.js/issues/16021)) + - respect the interceptor.$stateful flag + ([de7403](https://github.com/angular/angular.js/commit/de74034ddf6f92505ccdb61be413a6df2c723f87)) +- **Angular:** remove `angular.lowercase` and `angular.uppercase` + ([1daa4f](https://github.com/angular/angular.js/commit/1daa4f2231a89ee88345689f001805ffffa9e7de), + [#15445](https://github.com/angular/angular.js/issues/15445)) +- **$controller:** remove instantiating controllers defined on window + ([e269c1](https://github.com/angular/angular.js/commit/e269c14425a3209040f65c022658770e00a36f16), + [#15349](https://github.com/angular/angular.js/issues/15349), + [#15762](https://github.com/angular/angular.js/issues/15762)) + + +## New Features +- **angular.isArray:** support Array subclasses in `angular.isArray()` + ([e3ece2](https://github.com/angular/angular.js/commit/e3ece2fad9e1e6d47b5f06815ff186d7e6f44948), + [#15533](https://github.com/angular/angular.js/issues/15533), + [#15541](https://github.com/angular/angular.js/issues/15541)) +- **$sce:** handle URL sanitization through the `$sce` service + ([1e9ead](https://github.com/angular/angular.js/commit/1e9eadcd72dbbd5c67dae8328a63e535cfa91ff9)) +- **orderBy:** consider `null` and `undefined` greater than other values + ([1d8046](https://github.com/angular/angular.js/commit/1d804645f7656d592c90216a0355b4948807f6b8), + [#15294](https://github.com/angular/angular.js/issues/15294), + [#16376](https://github.com/angular/angular.js/issues/16376)) +- **$resource:** add support for `request` and `requestError` interceptors (#15674) + ([240a3d](https://github.com/angular/angular.js/commit/240a3ddbf12a9bb79754031be95dae4b6bd2dded), + [#5146](https://github.com/angular/angular.js/issues/5146)) +- **ngModelOptions:** add debounce catch-all + allow debouncing 'default' only + ([55ba44](https://github.com/angular/angular.js/commit/55ba44913e02650b56410aa9ab5eeea5d3492b68), + [#15411](https://github.com/angular/angular.js/issues/15411), + [#16335](https://github.com/angular/angular.js/issues/16335)) +- **$compile:** lower the `xlink:href` security context for SVG's `a` and `image` elements + ([6ccbfa](https://github.com/angular/angular.js/commit/6ccbfa65d60a3dc396d0cf6da21b993ad74653fd), + [#15736](https://github.com/angular/angular.js/issues/15736)) + + +## Performance Improvements +- **$rootScope:** allow $watchCollection use of expression input watching + ([97b00c](https://github.com/angular/angular.js/commit/97b00ca497676aaff8a803762a9f8c7ff4aa24dd)) +- **ngStyle:** use $watchCollection + ([15bbd3](https://github.com/angular/angular.js/commit/15bbd3e18cd89b91f7206a06c73d40e54a8a48a0), + [#15947](https://github.com/angular/angular.js/issues/15947)) +- **$compile:** do not use deepWatch in literal one-way bindings + ([fd4f01](https://github.com/angular/angular.js/commit/fd4f0111188b62773b99ab6eab38b4d2b5d8d727), + [#15301](https://github.com/angular/angular.js/issues/15301)) + + + + +## Breaking Changes + +### **jqLite** due to: + - **[b7d396](https://github.com/angular/angular.js/commit/b7d396b8b6e8f27a1f4556d58fc903321e8d532a)**: make removeData() not remove event handlers + +Before this commit `removeData()` invoked on an element removed its event +handlers as well. If you want to trigger a full cleanup of an element, change: + +```js +elem.removeData(); +``` + +to: + +```js +angular.element.cleanData(elem); +``` + +In most cases, though, cleaning up after an element is supposed to be done +only when it's removed from the DOM as well; in such cases the following: + +```js +elem.remove(); +``` + +will remove event handlers as well. + +### **$cookies** due to: + - **[73c646](https://github.com/angular/angular.js/commit/73c6467f1468353215dc689c019ed83aa4993c77)**: remove the deprecated $cookieStore factory + +The $cookieStore has been removed. Migrate to the $cookies service. Note that +for object values you need to use the `putObject` & `getObject` methods as +`get`/`put` will not correctly save/retrieve them. + +Before: +```js +$cookieStore.put('name', {key: 'value'}); +$cookieStore.get('name'); // {key: 'value'} +$cookieStore.remove('name'); +``` + +After: +```js +$cookies.putObject('name', {key: 'value'}); +$cookies.getObject('name'); // {key: 'value'} +$cookies.remove('name'); +``` + +### **$resource** due to: + - **[ea0585](https://github.com/angular/angular.js/commit/ea0585773bb93fd891576e2271254a17e15f1ddd)**: fix interceptors and success/error callbacks + +If you are not using `success` or `error` callbacks with `$resource`, +your app should not be affected by this change. + +If you are using `success` or `error` callbacks (with or without +response interceptors), one (subtle) difference is that throwing an +error inside the callbacks will not propagate to the returned +`$promise`. Therefore, you should try to use the promises whenever +possible. E.g.: + +```js +// Avoid +User.query(function onSuccess(users) { throw new Error(); }). + $promise. + catch(function onError() { /* Will not be called. */ }); + +// Prefer +User.query(). + $promise. + then(function onSuccess(users) { throw new Error(); }). + catch(function onError() { /* Will be called. */ }); +``` + +Finally, if you are using `success` or `error` callbacks with response +interceptors, the callbacks will now always run _after_ the interceptors +(and wait for them to resolve in case they return a promise). +Previously, the `error` callback was called before the `responseError` +interceptor and the `success` callback was synchronously called after +the `response` interceptor. E.g.: + +```js +var User = $resource('/api/users/:id', {id: '@id'}, { + get: { + method: 'get', + interceptor: { + response: function(response) { + console.log('responseInterceptor-1'); + return $timeout(1000).then(function() { + console.log('responseInterceptor-2'); + return response.resource; + }); + }, + responseError: function(response) { + console.log('responseErrorInterceptor-1'); + return $timeout(1000).then(function() { + console.log('responseErrorInterceptor-2'); + return $q.reject('Ooops!'); + }); + } + } + } +}); +var onSuccess = function(value) { console.log('successCallback', value); }; +var onError = function(error) { console.log('errorCallback', error); }; + +// Assuming the following call is successful... +User.get({id: 1}, onSuccess, onError); + // Old behavior: + // responseInterceptor-1 + // successCallback, {/* Promise object */} + // responseInterceptor-2 + // New behavior: + // responseInterceptor-1 + // responseInterceptor-2 + // successCallback, {/* User object */} + +// Assuming the following call returns an error... +User.get({id: 2}, onSuccess, onError); + // Old behavior: + // errorCallback, {/* Response object */} + // responseErrorInterceptor-1 + // responseErrorInterceptor-2 + // New behavior: + // responseErrorInterceptor-1 + // responseErrorInterceptor-2 + // errorCallback, Ooops! +``` + + - **[240a3d](https://github.com/angular/angular.js/commit/240a3ddbf12a9bb79754031be95dae4b6bd2dded)**: add support for `request` and `requestError` interceptors (#15674) + +Previously, calling a `$resource` method would synchronously call +`$http`. Now, it will be called asynchronously (regardless if a +`request`/`requestError` interceptor has been defined. + +This is not expected to affect applications at runtime, since the +overall operation is asynchronous already, but may affect assertions in +tests. For example, if you want to assert that `$http` has been called +with specific arguments as a result of a `$resource` call, you now need +to run a `$digest` first, to ensure the (possibly empty) request +interceptor promise has been resolved. + +Before: +```js +it('...', function() { + $httpBackend.expectGET('/api/things').respond(...); + var Things = $resource('/api/things'); + Things.query(); + + expect($http).toHaveBeenCalledWith(...); +}); +``` + +After: +```js +it('...', function() { + $httpBackend.expectGET('/api/things').respond(...); + var Things = $resource('/api/things'); + Things.query(); + $rootScope.$digest(); + + expect($http).toHaveBeenCalledWith(...); +}); +``` + +### **$templateRequest**: + - due to **[c617d6](https://github.com/angular/angular.js/commit/c617d6dceee5b000bfceda44ced22fc16b48b18b)**: give tpload error the correct namespace + +Previously the `tpload` error was namespaced to `$compile`. If you have +code that matches errors of the form `[$compile:tpload]` it will no +longer run. You should change the code to match +`[$templateRequest:tpload]`. + + - due to **([fb0099](https://github.com/angular/angular.js/commit/fb00991460cf69ae8bc7f1f826363d09c73c0d5e)**: always return the template that is stored in the cache + +The service now returns the result of `$templateCache.put()` when making a server request to the +template. Previously it would return the content of the response directly. +This now means if you are decorating `$templateCache.put()` to manipulate the template, you will +now get this manipulated result also on the first `$templateRequest` rather than only on subsequent +calls (when the template is retrived from the cache). +In practice this should not affect any apps, as it is unlikely that they rely on the template being +different in the first and subsequent calls. + +### **$animate** due to: + - **[16b82c](https://github.com/angular/angular.js/commit/16b82c6afe0ab916fef1d6ca78053b00bf5ada83)**: let cancel() reject the runner promise + +$animate.cancel(runner) now rejects the underlying +promise and calls the catch() handler on the runner +returned by $animate functions (enter, leave, move, +addClass, removeClass, setClass, animate). +Previously it would resolve the promise as if the animation +had ended successfully. + +Example: + +```js +var runner = $animate.addClass('red'); +runner.then(function() { console.log('success')}); +runner.catch(function() { console.log('cancelled')}); + +runner.cancel(); +``` + +Pre-1.7.0, this logs 'success', 1.7.0 and later it logs 'cancelled'. +To migrate, add a catch() handler to your animation runners. + +### **angular.isArray** due to: + - **[e3ece2](https://github.com/angular/angular.js/commit/e3ece2fad9e1e6d47b5f06815ff186d7e6f44948)**: support Array subclasses in `angular.isArray()` + +Previously, `angular.isArray()` was an alias for `Array.isArray()`. +Therefore, objects that prototypally inherit from `Array` where not +considered arrays. Now such objects are considered arrays too. + +This change affects several other methods that use `angular.isArray()` +under the hood, such as `angular.copy()`, `angular.equals()`, +`angular.forEach()`, and `angular.merge()`. + +This in turn affects how dirty checking treats objects that prototypally +inherit from `Array` (e.g. MobX observable arrays). AngularJS will now +be able to handle these objects better when copying or watching. + +### **$sce** due to: + - **[1e9ead](https://github.com/angular/angular.js/commit/1e9eadcd72dbbd5c67dae8328a63e535cfa91ff9)**: handle URL sanitization through the `$sce` service + +If you use `attrs.$set` for URL attributes (a[href] and img[src]) there will no +longer be any automated sanitization of the value. This is in line with other +programmatic operations, such as writing to the innerHTML of an element. + +If you are programmatically writing URL values to attributes from untrusted +input then you must sanitize it yourself. You could write your own sanitizer or copy +the private `$$sanitizeUri` service. + +Note that values that have been passed through the `$interpolate` service within the +`URL` or `MEDIA_URL` will have already been sanitized, so you would not need to sanitize +these values again. + +### **orderBy** due to: + - **[1d8046](https://github.com/angular/angular.js/commit/1d804645f7656d592c90216a0355b4948807f6b8)**: consider `null` and `undefined` greater than other values + +When using `orderBy` to sort arrays containing `null` values, the `null` values +will be considered "greater than" all other values, except for `undefined`. +Previously, they were sorted as strings. This will result in different (but more +intuitive) sorting order. + +Before: +```js +orderByFilter(['a', undefined, 'o', null, 'z']); +//--> 'a', null, 'o', 'z', undefined +``` + +After: +```js +orderByFilter(['a', undefined, 'o', null, 'z']); +//--> 'a', 'o', 'z', null, undefined +``` + +### **ngScenario** due to: + - **[0cd392](https://github.com/angular/angular.js/commit/0cd39217828b0ad53eaf731576af17d66c18ff60)**: completely remove the angular scenario runner + +The angular scenario runner end-to-end test framework has been +removed from the project and will no longer be available on npm +or bower starting with 1.7.0. +It was deprecated and removed from the documentation in 2014. +Applications that still use it should migrate to +[Protractor](http://www.protractortest.org). +Technically, it should also be possible to continue using an +older version of the scenario runner, as the underlying APIs have +not changed. However, we do not guarantee future compatibility. + +### **form** due to: + - **[223de5](https://github.com/angular/angular.js/commit/223de59e988dc0cc8b4ec3a045b7c0735eba1c77)**: set $submitted to true on child forms when parent is submitted + +Forms will now set $submitted on child forms when they are submitted. +For example: +``` +
+ + + + +
+``` + +Submitting this form will set $submitted on "parentform" and "childform". +Previously, it was only set on "parentform". + +This change was introduced because mixing form and ngForm does not create +logically separate forms, but rather something like input groups. +Therefore, child forms should inherit the submission state from their parent form. + +### **ngAria** due to: + - **[6d5ef3](https://github.com/angular/angular.js/commit/6d5ef34fc6a974cde73157ba94f9706723dd8f5b)**: do not set aria attributes on input[type="hidden"] + +ngAria no longer sets aria-* attributes on input[type="hidden"] with ngModel. +This can affect apps that test for the presence of aria attributes on hidden inputs. +To migrate, remove these assertions. +In actual apps, this should not have a user-facing effect, as the previous behavior +was incorrect, and the new behavior is correct for accessibility. + +### **ngModel, input** due to: + - **[74b04c](https://github.com/angular/angular.js/commit/74b04c9403af4fc7df5b6420f22c9f45a3e84140)**: improve handling of built-in named parsers + +*Custom* parsers that fail to parse on input types "email", "url", "number", "date", "month", +"time", "datetime-local", "week", do no longer set `ngModelController.$error[inputType]`, and +the `ng-invalid-[inputType]` class. Also, custom parsers on input type "range" do no +longer set `ngModelController.$error.number` and the `ng-invalid-number` class. + +Instead, any custom parsers on these inputs set `ngModelController.$error.parse` and +`ng-invalid-parse`. This change was made to make distinguishing errors from built-in parsers +and custom parsers easier. + +### **ngModelOptions** due to: + - **[55ba44](https://github.com/angular/angular.js/commit/55ba44913e02650b56410aa9ab5eeea5d3492b68)**: add debounce catch-all + allow debouncing 'default' only + +the 'default' key in 'debounce' now only debounces the default event, i.e. the event +that is added as an update trigger by the different input directives automatically. + +Previously, it also applied to other update triggers defined in 'updateOn' that +did not have a corresponding key in the 'debounce'. + +This behavior is now supported via a special wildcard / catch-all key: '*'. + +See the following example: + +Pre-1.7: +'mouseup' is also debounced by 500 milliseconds because 'default' is applied: +``` +ng-model-options="{ + updateOn: 'default blur mouseup', + debounce: { 'default': 500, 'blur': 0 } +} +``` + +1.7: +The pre-1.7 behavior can be re-created by setting '*' as a catch-all debounce value: +``` +ng-model-options="{ + updateOn: 'default blur mouseup', + debounce: { '*': 500, 'blur': 0 } +} +``` + +In contrast, when only 'default' is used, 'blur' and 'mouseup' are not debounced: +``` +ng-model-options="{ + updateOn: 'default blur mouseup', + debounce: { 'default': 500 } +} +``` + +### **input\[number\]** due to: + - **[aa3f95](https://github.com/angular/angular.js/commit/aa3f951330ec7b10b43ea884d9b5754e296770ec)**: validate min/max against viewValue + +`input[type=number]` with `ngModel` now validates the input for the `max`/`min` restriction against +the `ngModelController.$viewValue` instead of against the `ngModelController.$modelValue`. + +This affects apps that use `$parsers` or `$formatters` to transform the input / model value. + +If you rely on the $modelValue validation, you can overwrite the `min`/`max` validator from a custom directive, as seen in the following example directive definition object: + +``` +{ + restrict: 'A', + require: 'ngModel', + link: function(scope, element, attrs, ctrl) { + var maxValidator = ctrl.$validators.max; + + ctrl.$validators.max = function(modelValue, viewValue) { + return maxValidator(modelValue, modelValue); + }; + } +} +``` + +### **input** due to: + - **[656c8f](https://github.com/angular/angular.js/commit/656c8fa8f23b1277cc5c214c4d0237f3393afa1e)**: listen on "change" instead of "click" for radio/checkbox ngModels + +`input[radio]` and `input[checkbox]` now listen to the "change" event instead of the "click" event. +Most apps should not be affected, as "change" is automatically fired by browsers after "click" +happens. + +Two scenarios might need migration: + +- Custom click events: + +Before this change, custom click event listeners on radio / checkbox would be called after the +input element and `ngModel` had been updated, unless they were specifically registered before +the built-in click handlers. +After this change, they are called before the input is updated, and can call event.preventDefault() +to prevent the input from updating. + +If an app uses a click event listener that expects ngModel to be updated when it is called, it now +needs to register a change event listener instead. + +- Triggering click events: + +Conventional trigger functions: + +The change event might not be fired when the input element is not attached to the document. This +can happen in **tests** that compile input elements and +trigger click events on them. Depending on the browser (Chrome and Safari) and the trigger method, +the change event will not be fired when the input isn't attached to the document. + +Before: + +```js + it('should update the model', inject(function($compile, $rootScope) { + var inputElm = $compile('')($rootScope); + + inputElm[0].click(); // Or different trigger mechanisms, such as jQuery.trigger() + expect($rootScope.checkbox).toBe(true); + }); +``` + +With this patch, `$rootScope.checkbox` might not be true, because the click event +hasn't triggered the change event. To make the test, work append the inputElm to the app's +`$rootElement`, and the `$rootElement` to the `$document`. + +After: + +```js + it('should update the model', inject(function($compile, $rootScope, $rootElement, $document) { + var inputElm = $compile('')($rootScope); + + $rootElement.append(inputElm); + $document.append($rootElement); + + inputElm[0].click(); // Or different trigger mechanisms, such as jQuery.trigger() + expect($rootScope.checkbox).toBe(true); + }); +``` + +`triggerHandler()`: + +If you are using this jQuery / jqLite function on the input elements, you don't have to attach +the elements to the document, but instead change the triggered event to "change". This is because +`triggerHandler(event)` only triggers the exact event when it has been added by jQuery / jqLite. + +### **ngStyle** due to: + - **[15bbd3](https://github.com/angular/angular.js/commit/15bbd3e18cd89b91f7206a06c73d40e54a8a48a0)**: use $watchCollection + +Previously the use of deep watch by ng-style would trigger styles to be +re-applied when nested state changed. Now only changes to direct +properties of the watched object will trigger changes. + +### **$compile** due to: + - **[38f8c9](https://github.com/angular/angular.js/commit/38f8c97af74649ce224b6dd45f433cc665acfbfb)**: remove the preAssignBindingsEnabled flag + +Previously, the `$compileProvider.preAssignBindingsEnabled` flag was supported. +The flag controlled whether bindings were available inside the controller +constructor or only in the `$onInit` hook. The bindings are now no longer +available in the constructor. + +To migrate your code: + +1. If you haven't invoked `$compileProvider.preAssignBindingsEnabled()` you +don't have to do anything to migrate. + +2. If you specified `$compileProvider.preAssignBindingsEnabled(false)`, you +can remove that statement - since AngularJS 1.6.0 this is the default so your +app should still work even in AngularJS 1.6 after such removal. Afterwards, +migrating to AngularJS 1.7.0 shouldn't require any further action. + +3. If you specified `$compileProvider.preAssignBindingsEnabled(true)` you need +to first migrate your code so that the flag can be flipped to `false`. The +instructions on how to do that are available in the "Migrating from 1.5 to 1.6" +guide: +https://docs.angularjs.org/guide/migration#migrating-from-1-5-to-1-6 +Afterwards, remove the `$compileProvider.preAssignBindingsEnabled(true)` +statement. + + - **[6ccbfa](https://github.com/angular/angular.js/commit/6ccbfa65d60a3dc396d0cf6da21b993ad74653fd)**: lower the `xlink:href` security context for SVG's `a` and `image` elements + +In the unlikely case that an app relied on RESOURCE_URL whitelisting for the +purpose of binding to the `xlink:href` property of SVG's `` or `` +elements and if the values do not pass the regular URL sanitization, they will +break. + +To fix this you need to ensure that the values used for binding to the affected +`xlink:href` contexts are considered safe URLs, e.g. by whitelisting them in +`$compileProvider`'s `aHrefSanitizationWhitelist` (for `` elements) or +`imgSrcSanitizationWhitelist` (for `` elements). + + - **[fd4f01](https://github.com/angular/angular.js/commit/fd4f0111188b62773b99ab6eab38b4d2b5d8d727)**: do not use deepWatch in literal one-way bindings + +Previously when a literal value was passed into a directive/component via +one-way binding it would be watched with a deep watcher. + +For example, for ``, a new instance of the array +would be passed into the directive/component (and trigger $onChanges) not +only if `a` changed but also if any sub property of `a` changed such as +`a.b` or `a.b.c.d.e` etc. + +This also means a new but equal value for `a` would NOT trigger such a +change. + +Now literal values use an input-based watch similar to other directive/component +one-way bindings. In this context inputs are the non-constant parts of the +literal. In the example above the input would be `a`. Changes are only +triggered when the inputs to the literal change. + + - **[1cf728](https://github.com/angular/angular.js/commit/1cf728e209a9e0016068fac2769827e8f747760e)**: add `base[href]` to the list of RESOURCE_URL context attributes + +Previously, `` would not require `baseUrl` to +be trusted as a RESOURCE_URL. Now, `baseUrl` will be sent to `$sce`'s +RESOURCE_URL checks. By default, it will break unless `baseUrl` is of the same +origin as the application document. + +Refer to the +[`$sce` API docs](https://code.angularjs.org/snapshot/docs/api/ng/service/$sce) +for more info on how to trust a value in a RESOURCE_URL context. + +Also, concatenation in trusted contexts is not allowed, which means that the +following won't work: ``. + +Either construct complex values in a controller (recommended): + +```js +this.baseUrl = '/something/' + this.partialPath; +``` +```html + +``` + +Or use string concatenation in the interpolation expression (not recommended +except for the simplest of cases): + +```html + +``` + +### **ngTouch** due to: + - **[11d9ad](https://github.com/angular/angular.js/commit/11d9ad1eb25eaf5967195e424108207427835d50)**: remove ngClick override, `$touchProvider`, and `$touch` + +The `ngClick` directive from the ngTouch module has been removed, and with it the +corresponding `$touchProvider` and `$touch` service. + +If you have included ngTouch v1.5.0 or higher in your application, and have not +changed the value of `$touchProvider.ngClickOverrideEnabled()`, or injected and used the `$touch` +service, then there are no migration steps for your code. Otherwise you must remove references to +the provider and service. + +The `ngClick` override directive had been deprecated and by default disabled since v1.5.0, +because of buggy behavior in edge cases, and a general trend to avoid special touch based +overrides of click events. In modern browsers, it should not be necessary to use a touch override +library: + +- Chrome, Firefox, Edge, and Safari remove the 300ms delay when + `` is set. +- Internet Explorer 10+, Edge, Safari, and Chrome remove the delay on elements that have the + `touch-action` css property is set to `manipulation`. + +You can find out more in these articles: +https://developers.google.com/web/updates/2013/12/300ms-tap-delay-gone-away +https://developer.apple.com/library/content/releasenotes/General/WhatsNewInSafari/Articles/Safari_9_1.html#//apple_ref/doc/uid/TP40014305-CH10-SW8 +https://blogs.msdn.microsoft.com/ie/2015/02/24/pointer-events-w3c-recommendation-interoperable-touch-and-removing-the-dreaded-300ms-tap-delay/ + +### **Angular** due to: + - **[1daa4f](https://github.com/angular/angular.js/commit/1daa4f2231a89ee88345689f001805ffffa9e7de)**: remove `angular.lowercase` and `angular.uppercase` + +The helper functions `angular.lowercase` `and angular.uppercase` have +been removed. + +These functions have been deprecated since 1.5.0. They are internally +used, but should not be exposed as they contain special locale handling +(for Turkish) to maintain internal consistency regardless of user-set locale. + +Developers should generally use the built-ins `toLowerCase` and `toUpperCase` +or `toLocaleLowerCase` and `toLocaleUpperCase` for special cases. + +Further, we generally discourage using the angular.x helpers in application code. + +### **$controller** due to: + - **[e269c1](https://github.com/angular/angular.js/commit/e269c14425a3209040f65c022658770e00a36f16)**: remove instantiating controllers defined on window + +The option to instantiate controllers from constructors on the global `window` object +has been removed. Likewise, the deprecated `$controllerProvider.allowGlobals()` +method that could enable this behavior, has been removed. + +This behavior had been deprecated since AngularJS v1.3.0, because polluting the global scope +is bad. To migrate, remove the call to $controllerProvider.allowGlobals() in the config, and +register your controller via the Module API or the $controllerProvider, e.g. + +``` +angular.module('myModule', []).controller('myController', function() {...}); + +angular.module('myModule', []).config(function($controllerProvider) { + $controllerProvider.register('myController', function() {...}); +}); + +``` + +### **$rootScope** due to: + - **[c2b8fa](https://github.com/angular/angular.js/commit/c2b8fab0a480204374d561d6b9b3d47347ac5570)**: provide correct value of one-time bindings in watchGroup + +Previously when using `$watchGroup` the entries in `newValues` and +`oldValues` represented the *most recent change of each entry*. + +Now the entries in `oldValues` will always equal the `newValues` of the previous +call of the listener. This means comparing the entries in `newValues` and +`oldValues` can be used to determine which individual expressions changed. + +For example `$scope.$watchGroup(['a', 'b'], fn)` would previously: + +| Action | newValue | oldValue | +|----------|------------|------------| +| (init) | [undefined, undefined] | [undefined, undefined] | +| `a=1` | [1, undefined] | [undefined, undefined] | +| `a=2` | [2, undefined] | [1, undefined] | +| `b=3` | [2, 3] | [1, undefined] | + + +Now the `oldValue` will always equal the previous `newValue`: + +| Action | newValue | oldValue | +|----------|------------|------------| +| (init) | [undefined, undefined] | [undefined, undefined] | +| `a=1` | [1, undefined] | [undefined, undefined] | +| `a=2` | [2, undefined] | [1, undefined] | +| `b=3` | [2, 3] | [2, undefined] | + +Note the last call now shows `a === 2` in the `oldValues` array. + +This also makes the `oldValue` of one-time watchers more clear. Previously +the `oldValue` of a one-time watcher would remain `undefined` forever. For +example `$scope.$watchGroup(['a', '::b'], fn)` would previously: + +| Action | newValue | oldValue | +|----------|------------|------------| +| (init) | [undefined, undefined] | [undefined, undefined] | +| `a=1` | [1, undefined] | [undefined, undefined] | +| `b=2` | [1, 2] | [undefined, undefined] | +| `a=b=3` | [3, 2] | [1, undefined] | + +Where now the `oldValue` will always equal the previous `newValue`: + +| Action | newValue | oldValue | +|----------|------------|------------| +| (init) | [undefined, undefined] | [undefined, undefined] | +| `a=1` | [1, undefined] | [undefined, undefined] | +| `b=2` | [1, 2] | [1, undefined] | +| `a=b=3` | [3, 2] | [1, 2] | + +### **$interval** due to: + - **[a8bef9](https://github.com/angular/angular.js/commit/a8bef95127775d83d80daa4617c33227c4b443d4)**: throw when trying to cancel non-$interval promise + +`$interval.cancel()` will throw an error if called with a promise that +was not generated by `$interval()`. Previously, it would silently do +nothing. + +Before: +```js +var promise = $interval(doSomething, 1000, 5).then(doSomethingElse); +$interval.cancel(promise); // No error; interval NOT canceled. +``` + +After: +```js +var promise = $interval(doSomething, 1000, 5).then(doSomethingElse); +$interval.cancel(promise); // Throws error. +``` + +Correct usage: +```js +var promise = $interval(doSomething, 1000, 5); +var newPromise = promise.then(doSomethingElse); +$interval.cancel(promise); // Interval canceled. +``` + +### **$timeout** due to: + - **[336525](https://github.com/angular/angular.js/commit/3365256502344970f86355d3ace1cb4251ae9828)**: throw when trying to cancel non-$timeout promise + +`$timeout.cancel()` will throw an error if called with a promise that +was not generated by `$timeout()`. Previously, it would silently do +nothing. + +Before: +```js +var promise = $timeout(doSomething, 1000).then(doSomethingElse); +$timeout.cancel(promise); // No error; timeout NOT canceled. +``` + +After: +```js +var promise = $timeout(doSomething, 1000).then(doSomethingElse); +$timeout.cancel(promise); // Throws error. +``` + +Correct usage: +```js +var promise = $timeout(doSomething, 1000); +var newPromise = promise.then(doSomethingElse); +$timeout.cancel(promise); // Timeout canceled. +``` + + + +# 1.6.10 crystalline-persuasion (2018-04-17) + +## Bug Fixes +- **$compile:** + - correctly handle `null`/`undefined` href `attrs.$set()` + ([f04e04](https://github.com/angular/angular.js/commit/f04e04e0e63e0d30c29718abd5cae634901793b2), + [#16520](https://github.com/angular/angular.js/issues/16520)) + - throw error in `$onChanges` immediately + ([b7d1e0fbd](https://github.com/angular/angular.js/commit/983e27b628fd1eab653e2b3966d90a270f27cc93), + [#15578](https://github.com/angular/angular.js/issues/15578), + [#16492](https://github.com/angular/angular.js/issues/16492)) +- **input:** + - allow overriding timezone for date input types + ([4355de](https://github.com/angular/angular.js/commit/4355dee21d26667bb7f6f21bf75c081351315033), + [#16181](https://github.com/angular/angular.js/issues/16181), + [#13382](https://github.com/angular/angular.js/issues/13382), + [#16336](https://github.com/angular/angular.js/issues/16336)) + - take timezone into account when validating minimum and maximum in date types + ([2f0ac6](https://github.com/angular/angular.js/commit/2f0ac696cb09aec3e291bb8c9c8a1092cbe3a061), + [#16342](https://github.com/angular/angular.js/issues/16342), + [#16390](https://github.com/angular/angular.js/issues/16390)) + - fix composition mode in IE for Korean input + ([9a1b7c](https://github.com/angular/angular.js/commit/9a1b7c9fa135d1dae3f9b4ccf48f081675796e92), + [#6656](https://github.com/angular/angular.js/issues/6656), + [#16273](https://github.com/angular/angular.js/issues/16273)) +- **jqLite:** use XHTML-compliant HTML as input for jqLite + ([a0c55a](https://github.com/angular/angular.js/commit/a0c55af9858075ab268a88dd7a4464788a46f4b7), + [#6917](https://github.com/angular/angular.js/issues/6917), + [#16518](https://github.com/angular/angular.js/issues/16518)) +- **minErr:** update url to https + ([52e466](https://github.com/angular/angular.js/commit/52e46683bfcc0ce0dc9a3d2ee42b389508423799)) +- **$http:** set correct xhrStatus in response when using 'timeout' + ([1faf7e](https://github.com/angular/angular.js/commit/1faf7ec30d55bba107b18efbcf0ef07732c55b91)) +- **browserTrigger:** support CompositionEvent + ([c33fd1](https://github.com/angular/angular.js/commit/c33fd1325417fdc6d7d6abc90cd935130653b149)) + + +## New Features +- **$http:** support sending XSRF token to whitelisted origins + ([bc7757](https://github.com/angular/angular.js/commit/bc775759c88b2221c2bb71d2335bc233c93f43b0), + [#7862](https://github.com/angular/angular.js/issues/7862)) +- **minErr:** strip error url from error parameters + ([980b69](https://github.com/angular/angular.js/commit/980b69dcae73dd8a3d0b9d91b63fa7711cd0ba36)) +- **$sanitize:** support enhancing elements/attributes white-lists + ([ee8e05](https://github.com/angular/angular.js/commit/ee8e05cfafe086188fc318ed4115fb56ba335112), + [#5900](https://github.com/angular/angular.js/issues/5900), + [#16326](https://github.com/angular/angular.js/issues/16326)) +- **$rootScope:** allow suspending and resuming watchers on scope + ([efb822c58](https://github.com/angular/angular.js/commit/41d5c90f170cc054b0f8f88220c22ef1ef6cc0a6), + [#16308](https://github.com/angular/angular.js/issues/5301)) + + +# 1.6.9 fiery-basilisk (2018-02-02) + + +## Bug Fixes +- **input:** add `drop` event support for IE + ([5dc076](https://github.com/angular/angular.js/commit/5dc07667de00c5e85fd69c5b7b7fe4fb5fd65a77)) +- **ngMessages:** prevent memory leak from messages that are never attached + ([9d058d](https://github.com/angular/angular.js/commit/9d058de04bb78694b83179e9b97bc40214eca01a), + [#16389](https://github.com/angular/angular.js/issues/16389), + [#16404](https://github.com/angular/angular.js/issues/16404), + [#16406](https://github.com/angular/angular.js/issues/16406)) +- **ngTransclude:** remove terminal: true + ([1d826e](https://github.com/angular/angular.js/commit/1d826e2f1e941d14c3c56d7a0249f5796ba11f85), + [#16411](https://github.com/angular/angular.js/issues/16411), + [#16412](https://github.com/angular/angular.js/issues/16412)) +- **$sanitize:** sanitize `xml:base` attributes + ([b9ef65](https://github.com/angular/angular.js/commit/b9ef6585e10477fbbf912a971fe0b390bca692a6)) + + +## New Features +- **currencyFilter:** trim whitespace around an empty currency symbol + ([367390](https://github.com/angular/angular.js/commit/3673909896efb6ff47546caf7fc61549f193e043), + [#15018](https://github.com/angular/angular.js/issues/15018), + [#15085](https://github.com/angular/angular.js/issues/15085), + [#15105](https://github.com/angular/angular.js/issues/15105)) + + + +# 1.6.8 beneficial-tincture (2017-12-18) + + +## Bug Fixes +- **$location:** + - always decode special chars in `$location.url(https://github.com/value)` + ([2bdf71](https://github.com/angular/angular.js/commit/2bdf7126878c87474bb7588ce093d0a3c57b0026)) + - decode non-component special chars in Hashbang URLS + ([57b626](https://github.com/angular/angular.js/commit/57b626a673b7530399d3377dfe770165bec35f8a)) +- **ngModelController:** allow $overrideModelOptions to set updateOn + ([55516d](https://github.com/angular/angular.js/commit/55516da2dfc7c5798dce24e9fa930c5ac90c900c), + [#16351](https://github.com/angular/angular.js/issues/16351), + [#16364](https://github.com/angular/angular.js/issues/16364)) + + +## New Features +- **$parse:** add a hidden interface to retrieve an expression's AST + ([f33d95](https://github.com/angular/angular.js/commit/f33d95cfcff6fd0270f92a142df8794cca2013ad), + [#16253](https://github.com/angular/angular.js/issues/16253), + [#16260](https://github.com/angular/angular.js/issues/16260)) + + +# 1.6.7 imperial-backstroke (2017-11-24) + + +## Bug Fixes +- **$compile:** sanitize special chars in directive name + ([c4003f](https://github.com/angular/angular.js/commit/c4003fd03489f876b646f06838f4edb576bacf6f), + [#16314](https://github.com/angular/angular.js/issues/16314), + [#16278](https://github.com/angular/angular.js/issues/16278)) +- **$location:** do not decode forward slashes in the path in HTML5 mode + ([e06ebf](https://github.com/angular/angular.js/commit/e06ebfdbb558544602fe9da4d7d98045a965f468), + [#16312](https://github.com/angular/angular.js/issues/16312)) +- **sanitizeUri:** sanitize URIs that contain IDEOGRAPHIC SPACE chars + ([ddeb1d](https://github.com/angular/angular.js/commit/ddeb1df15a23de93eb95dbe202e83e93673e1c4e), + [#16288](https://github.com/angular/angular.js/issues/16288)) +- **$rootScope:** fix potential memory leak when removing scope listeners + ([358a69](https://github.com/angular/angular.js/commit/358a69fa8b89b251ee44e523458d6c7f40b92b2d), + [#16135](https://github.com/angular/angular.js/issues/16135), + [#16161](https://github.com/angular/angular.js/issues/16161)) +- **http:** do not allow encoded callback params in jsonp requests + ([569e90](https://github.com/angular/angular.js/commit/569e906a5818271416ad0b749be2f58dc34938bd)) +- **ngMock:** pass unexpected request failures in `$httpBackend` to the error handler + ([1555a4](https://github.com/angular/angular.js/commit/1555a4911ad5360c145c0ddc8ec6c4bf9a381c13), + [#16150](https://github.com/angular/angular.js/issues/16150), + [#15855](https://github.com/angular/angular.js/issues/15855)) +- **ngAnimate:** don't close transitions when child transitions close + ([1391e9](https://github.com/angular/angular.js/commit/1391e99c7f73795180b792af21ad4402f96e225d), + [#16210](https://github.com/angular/angular.js/issues/16210)) +- **ngMock.browserTrigger:** add 'bubbles' to Transition/Animation Event + ([7a5f06](https://github.com/angular/angular.js/commit/7a5f06d55d123a39bb7b030667fb1ab672939598)) + + +## New Features +- **$sanitize, $compileProvider, linky:** add support for the "sftp" protocol in links + ([a675ea](https://github.com/angular/angular.js/commit/a675ea034366fbb0fcf0d73fed65216aa99bce11), + [#16102](https://github.com/angular/angular.js/issues/16102)) +- **ngModel.NgModelController:** expose $processModelValue to run model -> view pipeline + ([145194](https://github.com/angular/angular.js/commit/14519488ce9218aa891d34e89fc3271fd4ed0f04), + [#3407](https://github.com/angular/angular.js/issues/3407), + [#10764](https://github.com/angular/angular.js/issues/10764), + [#16237](https://github.com/angular/angular.js/issues/16237)) +- **$injector:** ability to load new modules after bootstrapping + ([6e78fe](https://github.com/angular/angular.js/commit/6e78fee73258bb0ae36414f9db2e8734273e481b)) + + +## Performance Improvements +- **jqLite:** + - avoid setting class attribute when not changed + ([9c95f6](https://github.com/angular/angular.js/commit/9c95f6d5e00ee7e054aabb3e363f5bfb3b7b4103)) + - avoid repeated add/removeAttribute in jqLiteRemoveClass + ([cab9eb](https://github.com/angular/angular.js/commit/cab9ebfd5a02e897f802bf6321b8471e4843c5d3), + [#16078](https://github.com/angular/angular.js/issues/16078), + [#16131](https://github.com/angular/angular.js/issues/16131)) + + + +# 1.6.6 interdimensional-cable (2017-08-18) + + +## Bug Fixes +- **$httpParamSerializer:** ignore functions + ([b51ded](https://github.com/angular/angular.js/commit/b51ded67366865f36c5781dd5d9b801488ec95ea), + [#16133](https://github.com/angular/angular.js/issues/16133)) +- **$resource:** do not throw when calling old `$cancelRequest()` + ([009ebe](https://github.com/angular/angular.js/commit/009ebec64c81d11b280c635167050e8906e191c6), + [#16037](https://github.com/angular/angular.js/issues/16037)) +- **$parse:** + - do not shallow-watch computed property keys + ([750465](https://github.com/angular/angular.js/commit/7504656a26202de591e4ac9674333254304edf8a)) + - support constants in computed keys + ([9d6c3f](https://github.com/angular/angular.js/commit/9d6c3f3ec233279885e37a250d25860d5c15f716)) +- **$http:** do not throw error if `Content-Type` is not `application/json` but response is JSON-like + ([2e1163](https://github.com/angular/angular.js/commit/2e1163ef5cb56d1933e8ecd7b74020b9df9c6693), + [#16027](https://github.com/angular/angular.js/issues/16027), + [#16075](https://github.com/angular/angular.js/issues/16075)) + + +## New Features +- **$compile:** add `strictComponentBindingsEnabled()` method + ([3ec181](https://github.com/angular/angular.js/commit/3ec1819b913c8edf0649e06217dbd5920f29f126), + [#16129](https://github.com/angular/angular.js/issues/16129)) +- **$resource:** add resource to response for error interceptors + ([9256db](https://github.com/angular/angular.js/commit/9256dbc4201343ce5cd63a9eadf98da4793f45af), + [#16109](https://github.com/angular/angular.js/issues/16109)) +- **$http:** allow differentiation between XHR completion, error, abort, timeout + ([5e2bc5](https://github.com/angular/angular.js/commit/5e2bc5bbf347a9dfadc08b1514b8be06fd550913), + [#15924](https://github.com/angular/angular.js/issues/15924), + [#15847](https://github.com/angular/angular.js/issues/15847)) + + + +# 1.6.5 toffee-salinization (2017-07-03) + + +## Bug Fixes +- **core:** + - correctly detect Error instances from different contexts + ([6daca0](https://github.com/angular/angular.js/commit/6daca023e42098f7098b9bf153c8e53a17af84f1), + [#15868](https://github.com/angular/angular.js/issues/15868), + [#15872](https://github.com/angular/angular.js/issues/15872)) + - deprecate `angular.merge` + ([dc41f4](https://github.com/angular/angular.js/commit/dc41f465baae9bc91418a61f446596157c530b6e), + [#12653](https://github.com/angular/angular.js/issues/12653), + [#14941](https://github.com/angular/angular.js/issues/14941), + [#15180](https://github.com/angular/angular.js/issues/15180), + [#15992](https://github.com/angular/angular.js/issues/15992), + [#16036](https://github.com/angular/angular.js/issues/16036)) +- **ngOptions:** + - re-render after empty option has been removed + ([510d0f](https://github.com/angular/angular.js/commit/510d0f946fa1a443ad43fa31bc9337676ef31332)) + - allow empty option to be removed and re-added + ([71b4da](https://github.com/angular/angular.js/commit/71b4daa4e10b6912891927ee2a7930c604b538f8)) + - select unknown option if unmatched model does not match empty option + ([17d34b](https://github.com/angular/angular.js/commit/17d34b7a983a0ef63f6cf404490385c696fb0da1)) +- **orderBy:** guarantee stable sort + ([e50ed4](https://github.com/angular/angular.js/commit/e50ed4da9e8177168f67da68bdf02f07da4e7bcf), + [#14881](https://github.com/angular/angular.js/issues/14881), + [#15914](https://github.com/angular/angular.js/issues/15914)) +- **$parse:** + - do not shallow-watch inputs to one-time intercepted expressions + ([6e3b5a](https://github.com/angular/angular.js/commit/6e3b5a57cd921823f3eca7200a79ac5c2ef0567a)) + - standardize one-time literal vs non-literal and interceptors + ([f003d9](https://github.com/angular/angular.js/commit/f003d93a3dd052dccddef41125d9c51034ac3605)) + - do not shallow-watch inputs when wrapped in an interceptor fn + ([aac562](https://github.com/angular/angular.js/commit/aac5623247a86681cbe0e1c8179617b816394c1d), + [#15905](https://github.com/angular/angular.js/issues/15905)) + - always re-evaluate filters within literals when an input is an object + ([ec9768](https://github.com/angular/angular.js/commit/ec97686f2f4a5481cc806462313a664fc7a1c893), + [#15964](https://github.com/angular/angular.js/issues/15964), + [#15990](https://github.com/angular/angular.js/issues/15990)) +- **$sanitize:** use appropriate inert document strategy for Firefox and Safari + ([8f31f1](https://github.com/angular/angular.js/commit/8f31f1ff43b673a24f84422d5c13d6312b2c4d94)) +- **$timeout/$interval:** do not trigger a digest on cancel + ([a222d0](https://github.com/angular/angular.js/commit/a222d0b452622624dc498ef0b9d3c43647fd4fbc), + [#16057](https://github.com/angular/angular.js/issues/16057), + [#16064](https://github.com/angular/angular.js/issues/16064))
+ This change might affect the use of `$timeout.flush()` in unit tests. See the commit message for + more info. +- **ngMock/$interval:** add support for zero-delay intervals in tests + ([a1e3f8](https://github.com/angular/angular.js/commit/a1e3f8728e0a80396f980e48f8dc68dde6721b2b), + [#15952](https://github.com/angular/angular.js/issues/15952), + [#15953](https://github.com/angular/angular.js/issues/15953)) +- **angular-loader:** do not depend on "closure" globals that may not be available + ([a3226d](https://github.com/angular/angular.js/commit/a3226d01fadaf145713518dc5b8022b581c34e81), + [#15880](https://github.com/angular/angular.js/issues/15880), + [#15881](https://github.com/angular/angular.js/issues/15881)) + + +## New Features +- **select:** expose info about selection state in controller + ([0b962d](https://github.com/angular/angular.js/commit/0b962d4881e98327a91c37f7317da557aa991663), + [#13172](https://github.com/angular/angular.js/issues/13172), + [#10127](https://github.com/angular/angular.js/issues/10127)) +- **$animate:** add support for `customFilter` + ([ab114a](https://github.com/angular/angular.js/commit/ab114af8508bdbdb1fa5fd1e070d08818d882e28), + [#14891](https://github.com/angular/angular.js/issues/14891)) +- **$compile:** overload `.component()` to accept object map of components + ([210112](https://github.com/angular/angular.js/commit/2101126ce72308d8fc468ca2411bb9972e614f79), + [#14579](https://github.com/angular/angular.js/issues/14579), + [#16062](https://github.com/angular/angular.js/issues/16062)) +- **$log:** log all parameters in IE 9, not just the first two. + ([3671a4](https://github.com/angular/angular.js/commit/3671a43be43d05b00c90dfb3a3f746c013139581)) +- **ngMock:** describe unflushed http requests + ([d9128e](https://github.com/angular/angular.js/commit/d9128e7b2371ab2bb5169ba854b21c78baa784d2), + [#10596](https://github.com/angular/angular.js/issues/10596), + [#15928](https://github.com/angular/angular.js/issues/15928)) + + +## Performance Improvements +- **ngOptions:** prevent initial options repainting + ([ff52b1](https://github.com/angular/angular.js/commit/ff52b188a759f2cc7ee6ee78a8c646c2354a47eb), + [#15801](https://github.com/angular/angular.js/issues/15801), + [#15812](https://github.com/angular/angular.js/issues/15812), + [#16071](https://github.com/angular/angular.js/issues/16071)) +- **$animate:** + - avoid unnecessary computations if animations are globally disabled + ([ce5ffb](https://github.com/angular/angular.js/commit/ce5ffbf667464bd58eae4c4af0917eb2685f1f6a), + [#14914](https://github.com/angular/angular.js/issues/14914)) + - do not retrieve `className` unless `classNameFilter` is used + ([275978](https://github.com/angular/angular.js/commit/27597887379a1904cd86832602e286894b449a75)) + + + + +# 1.6.4 phenomenal-footnote (2017-03-31) + + +## Bug Fixes +- **$parse:** + - standardize one-time literal vs non-literal and interceptors + ([60394a](https://github.com/angular/angular.js/commit/60394a9d91dad8932fa900af7c8529837f1d4557), + [#15858](https://github.com/angular/angular.js/issues/15858)) + - fix infinite digest errors when watching objects with .valueOf in literals + ([f5ddb1](https://github.com/angular/angular.js/commit/f5ddb10b56676c2ad912ce453acb87f0a7a94e01), + [#15867](https://github.com/angular/angular.js/issues/15867)) +- **ngModel:** prevent internal scope reference from being copied + ([e1f8a6](https://github.com/angular/angular.js/commit/e1f8a6e82bb8a70079ef3db9a891b1c08b5bae31), + [#15833](https://github.com/angular/angular.js/issues/15833)) +- **jqLite:** make jqLite invoke jqLite.cleanData as a method + ([9cde98](https://github.com/angular/angular.js/commit/9cde98cbc770f8d33fc074ba563b7ab6e2baaf8b), + [#15846](https://github.com/angular/angular.js/issues/15846)) +- **$http:** throw more informative error on invalid JSON response + ([df8887](https://github.com/angular/angular.js/commit/df88873bb79213057057adb47151b626a7ec0e5d), + [#15695](https://github.com/angular/angular.js/issues/15695), + [#15724](https://github.com/angular/angular.js/issues/15724)) +- **dateFilter:** correctly handle newlines in `format` string + ([982271](https://github.com/angular/angular.js/commit/9822711ad2a401c2449239edc13d18b301714757), + [#15794](https://github.com/angular/angular.js/issues/15794), + [#15792](https://github.com/angular/angular.js/issues/15792)) + + +## New Features +- **$resource:** add `hasBody` action configuration option + ([a9f987](https://github.com/angular/angular.js/commit/a9f987a0c9653246ea471a89197907d94c0cea2a), + [#10128](https://github.com/angular/angular.js/issues/10128), + [#12181](https://github.com/angular/angular.js/issues/12181)) + + + +# 1.6.3 scriptalicious-bootstrapping (2017-03-08) + + +## Bug Fixes +- **AngularJS:** + - do not auto-bootstrap if the `src` exists but is empty + ([3536e8](https://github.com/angular/angular.js/commit/3536e83d8a085b02bd6dcec8324800b7e6c734e4)) + - do not auto bootstrap if the currentScript has been clobbered + ([95f964](https://github.com/angular/angular.js/commit/95f964b827b6f5b5aab10af54f7831316c7a9935)) + - do not auto-bootstrap if the script source is bad and inside SVG + ([c8f78a](https://github.com/angular/angular.js/commit/c8f78a8ca9debc33a6deaf951f344b8d372bf210)) +- **$log:** don't parse error stacks manually outside of IE/Edge + ([64e5af](https://github.com/angular/angular.js/commit/64e5afc4786fdfd850c6bdb488a5aa2b8b077f74), + [#15590](https://github.com/angular/angular.js/issues/15590), + [#15767](https://github.com/angular/angular.js/issues/15767)) +- **$sanitize:** prevent clobbered elements from freezing the browser + ([3bb1dd](https://github.com/angular/angular.js/commit/3bb1dd5d7f7dcde6fea5a3148f8f10e92f451e9d), + [#15699](https://github.com/angular/angular.js/issues/15699)) +- **$animate:** + - reset `classNameFilter` to `null` when a disallowed RegExp is used + ([a584fb](https://github.com/angular/angular.js/commit/a584fb6e1569fc1dd85e23b251a7c126edc2dd5b), + [#14913](https://github.com/angular/angular.js/issues/14913)) + - improve detection on `ng-animate` in `classNameFilter` RegExp + ([1f1331](https://github.com/angular/angular.js/commit/1f13313f403381581e1c31c57ebfe7a96546c6e4), + [#14806](https://github.com/angular/angular.js/issues/14806)) +- **filterFilter:** don't throw if `key.charAt` is not a function + ([f27d19](https://github.com/angular/angular.js/commit/f27d19ed606bf05ba41698159ebbc5fbc195033e), + [#15644](https://github.com/angular/angular.js/issues/15644), + [#15660](https://github.com/angular/angular.js/issues/15660)) +- **select:** + - add attribute "selected" for `select[multiple]` + ([851367](https://github.com/angular/angular.js/commit/8513674911300b27d518383a905fde9b3f25f7ae)) + - keep original selection when using shift to add options in IE/Edge + ([97b74a](https://github.com/angular/angular.js/commit/97b74ad6fbcbc4b63e37e9eb44962d6f8de83e8b), + [#15675](https://github.com/angular/angular.js/issues/15675), + [#15676](https://github.com/angular/angular.js/issues/15676)) +- **$jsonpCallbacks:** allow `$window` to be mocked in unit tests + ([5ca0de](https://github.com/angular/angular.js/commit/5ca0de64873c32ab2f540a3226e73c4175a15c50), + [#15685](https://github.com/angular/angular.js/issues/15685), + [#15686](https://github.com/angular/angular.js/issues/15686)) + + +## New Features +- **info:** add `angularVersion` info to each module + ([1e582e](https://github.com/angular/angular.js/commit/1e582e4fa486f340150bba95927f1b26d9142de2)) +- **$injector:** add new `modules` property + ([742123](https://github.com/angular/angular.js/commit/7421235f247e5b7113345401bc5727cfbf81ddc2)) +- **Module:** add `info()` method + ([09ba69](https://github.com/angular/angular.js/commit/09ba69078de6ba52c70571b82b6205929f6facc5), + [#15225](https://github.com/angular/angular.js/issues/15225)) +- **errorHandlingConfig:** make the depth for object stringification in errors configurable + ([4a5eaf](https://github.com/angular/angular.js/commit/4a5eaf7bec85ceca8b934ebaff4d1834a1a09f57), + [#15402](https://github.com/angular/angular.js/issues/15402), + [#15433](https://github.com/angular/angular.js/issues/15433)) + + + +# 1.6.2 llamacorn-lovehug (2017-02-07) + + +## Bug Fixes +- **$compile:** + - do not swallow thrown errors in testsg + ([0377c6](https://github.com/angular/angular.js/commit/0377c6f0e890cb4ed3eb020b96720b4b34f75df3), + [#15629](https://github.com/angular/angular.js/issues/15629), + [#15631](https://github.com/angular/angular.js/issues/15631)) + - allow the usage of "$" in isolate scope property alias + ([7f2af3](https://github.com/angular/angular.js/commit/7f2af3f923e7a3f85c8862d0ed57d21c72eae904), + [#15594](https://github.com/angular/angular.js/issues/15594)) +- **$location:** correctly handle external URL change during `$digest` + ([b60761](https://github.com/angular/angular.js/commit/b607618342d6c4fab364966fe05f152be6bd4d5f), + [#11075](https://github.com/angular/angular.js/issues/11075), + [#12571](https://github.com/angular/angular.js/issues/12571), + [#15556](https://github.com/angular/angular.js/issues/15556), + [#15561](https://github.com/angular/angular.js/issues/15561)) +- **$browser:** detect external changes in `history.state` + ([fa50fb](https://github.com/angular/angular.js/commit/fa50fbaf57b3437be7a410ecaba7008dbe0ef239)) +- **$resource:** + - do not swallow errors in `success` callback + ([27146e](https://github.com/angular/angular.js/commit/27146e8a7fad54c1342179b6d291b1b5c2ebe816), + [#15624](https://github.com/angular/angular.js/issues/15624), + [#15628](https://github.com/angular/angular.js/issues/15628)) + - correctly unescape `/\.` even if `\.` comes from a param value + ([419a48](https://github.com/angular/angular.js/commit/419a4813e354496bdf0df44e3f8afaa198df1ab1), + [#15627](https://github.com/angular/angular.js/issues/15627)) + - delete `$cancelRequest()` in `toJSON()` + ([086c5d](https://github.com/angular/angular.js/commit/086c5d0354db8cb3d106b9ff966fb48d6fb46ef8), + [#15244](https://github.com/angular/angular.js/issues/15244)) +- **$animate:** correctly animate transcluded clones with `templateUrl` + ([f01212](https://github.com/angular/angular.js/commit/f01212ab5287ac7a154da7d75037ed444e81eb34), + [#15510](https://github.com/angular/angular.js/issues/15510), + [#15514](https://github.com/angular/angular.js/issues/15514)) +- **$route:** make asynchronous tasks count as pending requests + ([eb968c](https://github.com/angular/angular.js/commit/eb968c4a6884838db05369a04459066424c5bba8), + [#14159](https://github.com/angular/angular.js/issues/14159)) +- **$parse:** make sure ES6 object computed properties are watched + ([5e418b](https://github.com/angular/angular.js/commit/5e418b1145a1045da598c7863e785d647ea83850), + [#15678](https://github.com/angular/angular.js/issues/15678)) +- **$sniffer:** allow `history` for NW.js apps + ([4a593d](https://github.com/angular/angular.js/commit/4a593db79ba1e21a6aa600a82cf6d757cad94d01), + [#15474](https://github.com/angular/angular.js/issues/15474), + [#15633](https://github.com/angular/angular.js/issues/15633)) +- **input:** fix `step` validation for `input[type=number/range]` + ([c95a67](https://github.com/angular/angular.js/commit/c95a6737fbd277e40c064bd9f68f383bf119505c), + [#15504](https://github.com/angular/angular.js/issues/15504), + [#15506](https://github.com/angular/angular.js/issues/15506)) +- **select:** keep `ngModel` when selected option is recreated by `ngRepeat` + ([131af8](https://github.com/angular/angular.js/commit/131af8272d269a541d04cb522c264a91e0ec8b6a), + [#15630](https://github.com/angular/angular.js/issues/15630), + [#15632](https://github.com/angular/angular.js/issues/15632)) +- **ngValue:** correctly update the `value` property when `value` is undefined + ([05aab6](https://github.com/angular/angular.js/commit/05aab660ce74f526f2110d3b5faf9a5b4f4e664b) + [#15603](https://github.com/angular/angular.js/issues/15603), + [#15605](https://github.com/angular/angular.js/issues/15605)) +- **angularInit:** allow auto-bootstrapping from inline script + ([bb464d](https://github.com/angular/angular.js/commit/bb464d16b434b9e2de2fecf80c192d4741cba879), + [#15567](https://github.com/angular/angular.js/issues/15567), + [#15571](https://github.com/angular/angular.js/issues/15571)) +- **ngMockE2E:** ensure that mocked `$httpBackend` uses correct `$browser` + ([bd63b2](https://github.com/angular/angular.js/commit/bd63b2235cd410251cb83eebd9a47d3102830b6b), + [#15593](https://github.com/angular/angular.js/issues/15593)) + + +## New Features +- **ngModel:** add `$overrideModelOptions` support + ([2546c2](https://github.com/angular/angular.js/commit/2546c29f811b68eea4d68be7fa1c8f7bb562dc11), + [#15415](https://github.com/angular/angular.js/issues/15415)) +- **$parse:** allow watching array/object literals with non-primitive values + ([25f008](https://github.com/angular/angular.js/commit/25f008f541d68b09efd7b428b648c6d4899e6972), + [#15301](https://github.com/angular/angular.js/issues/15301)) + + + + +# 1.5.11 princely-quest (2017-01-13) + + +## Bug Fixes +- **$compile:** allow the usage of "$" in isolate scope property alias + ([e75fbc](https://github.com/angular/angular.js/commit/e75fbc494e6a0da6a9231b40bb0382431b62be07), + [#15586](https://github.com/angular/angular.js/issues/15586), + [#15594](https://github.com/angular/angular.js/issues/15594)) +- **angularInit:** allow auto-bootstrapping from inline script + ([41aa91](https://github.com/angular/angular.js/commit/41aa9125b9aaf771addb250642f524a4e6f9d8d3), + [#15567](https://github.com/angular/angular.js/issues/15567), + [#15571](https://github.com/angular/angular.js/issues/15571)) +- **$resource:** delete `$cancelRequest()` in `toJSON()` + ([4f3858](https://github.com/angular/angular.js/commit/4f3858e7c371f87534397f45b9d002add33b00cc), + [#15244](https://github.com/angular/angular.js/issues/15244)) +- **$$cookieReader:** correctly handle forbidden access to `document.cookie` + ([6933cf](https://github.com/angular/angular.js/commit/6933cf64fe51f54b10d1639f2b95bab3c1178df9), + [#15523](https://github.com/angular/angular.js/issues/15523), + [#15532](https://github.com/angular/angular.js/issues/15532)) + + + + +# 1.6.1 promise-rectification (2016-12-23) + + +## Bug Fixes +- **$q:** Add traceback to unhandled promise rejections + ([174cb4](https://github.com/angular/angular.js/commit/174cb4a8c81e25581da5b452c2bb43b0fa377a9b), + [#14631](https://github.com/angular/angular.js/issues/14631)) +- **$$cookieReader:** correctly handle forbidden access to `document.cookie` + ([33f769](https://github.com/angular/angular.js/commit/33f769b0a1214055c16fb59adad4897bf53d62bf), + [#15523](https://github.com/angular/angular.js/issues/15523)) +- **ngOptions:** do not unset the `selected` property unless necessary + ([bc4844](https://github.com/angular/angular.js/commit/bc4844d3b297d80aecef89aa1b32615024decedc), + [#15477](https://github.com/angular/angular.js/issues/15477)) +- **ngModelOptions:** work correctly when on the template of `replace` directives + ([5f8ed6](https://github.com/angular/angular.js/commit/5f8ed63f2ab02ffb9c21bf9c29d27c851d162e26), + [#15492](https://github.com/angular/angular.js/issues/15492)) +- **ngClassOdd/Even:** add/remove the correct classes when expression/`$index` change simultaneously + ([d52864](https://github.com/angular/angular.js/commit/d528644fe3e9ffd43999e7fc67806059f9e1083e)) +- **jqLite:** silently ignore `after()` if element has no parent + ([3d68b9](https://github.com/angular/angular.js/commit/3d68b9502848ff6714ef89bfb95b8e70ae34eff6), + [#15331](https://github.com/angular/angular.js/issues/15331), + [#15475](https://github.com/angular/angular.js/issues/15475)) +- **$rootScope:** when adding/removing watchers during $digest + ([163aca](https://github.com/angular/angular.js/commit/163aca336d7586a45255787af41b14b2a12361dd), + [#15422](https://github.com/angular/angular.js/issues/15422)) + + +## Performance Improvements +- **ngClass:** avoid unnecessary `.data()` accesses, deep-watching and copies + ([1d3b65](https://github.com/angular/angular.js/commit/1d3b65adc2c22ff662159ef910089cf10d1edb7b), + [#14404](https://github.com/angular/angular.js/issues/14404)) + + + + +# 1.5.10 asynchronous-synchronization (2016-12-15) + + +## Bug Fixes +- **$compile:** + - don't throw tplrt error when there is whitespace around a top-level comment + ([12752f](https://github.com/angular/angular.js/commit/12752f66ac425ab38a5ee574a4bfbf3516adc42c), + [#15108](https://github.com/angular/angular.js/issues/15108)) + - clean up `@`-binding observers when re-assigning bindings + ([f3cb6e](https://github.com/angular/angular.js/commit/f3cb6e309aa1f676e5951ac745fa886d3581c2f4), + [#15268](https://github.com/angular/angular.js/issues/15268)) + - set attribute value even if `ngAttr*` contains no interpolation + ([229799](https://github.com/angular/angular.js/commit/22979904fb754c59e9f6ee5d8763e3b8de0e18c2), + [#15133](https://github.com/angular/angular.js/issues/15133)) + - `bindToController` should work without `controllerAs` + ([944989](https://github.com/angular/angular.js/commit/9449893763a4fd95ee8ff78b53c6966a874ec9ae), + [#15088](https://github.com/angular/angular.js/issues/15088)) + - do not overwrite values set in `$onInit()` for `<`-bound literals + ([07e1ba](https://github.com/angular/angular.js/commit/07e1ba365fb5e8a049be732bd7b62f71e0aa1672), + [#15118](https://github.com/angular/angular.js/issues/15118)) + - avoid calling `$onChanges()` twice for `NaN` initial values + ([0cf5be](https://github.com/angular/angular.js/commit/0cf5be52642f7e9d81a708b3005042eac6492572)) +- **$location:** prevent infinite digest with IDN urls in Edge + ([4bf892](https://github.com/angular/angular.js/commit/4bf89218130d434771089fdfe643490b8d2ee259), + [#15217](https://github.com/angular/angular.js/issues/15217)) +- **$rootScope:** correctly handle adding/removing watchers during `$digest` + ([a9708d](https://github.com/angular/angular.js/commit/a9708de84b50f06eacda33834d5bbdfc97c97f37), + [#15422](https://github.com/angular/angular.js/issues/15422)) +- **$sce:** fix `adjustMatcher` to replace multiple `*` and `**` + ([78eecb](https://github.com/angular/angular.js/commit/78eecb43dbb0500358d333aea8955bd0646a7790)) +- **jqLite:** silently ignore `after()` if element has no parent + ([77ed85](https://github.com/angular/angular.js/commit/77ed85bcd3be057a5a79231565ac7accc6d644c6), + [#15331](https://github.com/angular/angular.js/issues/15331)) +- **input[radio]:** use non-strict comparison for checkedness + ([593a50](https://github.com/angular/angular.js/commit/593a5034841b3b7661d3bcbdd06b7a9d0876fd34)) +- **select, ngOptions:** + - let `ngValue` take precedence over option text with multiple interpolations + ([5b7ec8](https://github.com/angular/angular.js/commit/5b7ec8c84e88ee08aacaf9404853eda0016093f5), + [#15413](https://github.com/angular/angular.js/issues/15413)) + - don't add comment nodes as empty options + ([1d29c9](https://github.com/angular/angular.js/commit/1d29c91c3429de96e4103533752700d1266741be), + [#15454](https://github.com/angular/angular.js/issues/15454)) +- **ngClassOdd/Even:** add/remove the correct classes when expression/`$index` change simultaneously + ([e3d020](https://github.com/angular/angular.js/commit/e3d02070ab8a02c818dcc5114db6fba9d3f385d6)) +- **$sanitize:** reduce stack height in IE <= 11 + ([862dc2](https://github.com/angular/angular.js/commit/862dc2532f8126a4a71fd3d957884ba6f11f591c), + [#14928](https://github.com/angular/angular.js/issues/14928)) +- **ngMock/$controller:** respect `$compileProvider.preAssignBindingsEnabled()` + ([75c83f](https://github.com/angular/angular.js/commit/75c83ff3195931859a099f7a95bf81d32abf2eb3)) + + +## New Features +- **bootstrap:** do not bootstrap from unknown schemes with a different origin + ([bdeb33](https://github.com/angular/angular.js/commit/bdeb3392a8719131ab2b993f2a881c43a2860f92), + [#15428](https://github.com/angular/angular.js/issues/15428)) +- **$anchorScroll:** convert numeric hash targets to string + ([a52640](https://github.com/angular/angular.js/commit/a5264090b66ad0cf9a93de84bb7b307868c0edef), + [#14680](https://github.com/angular/angular.js/issues/14680)) +- **$compile:** + - add `preAssignBindingsEnabled` option + ([f86576](https://github.com/angular/angular.js/commit/f86576def44005f180a66e3aa12d6cc73c1ac72c)) + - throw error when directive name or factory function is invalid + ([5c9399](https://github.com/angular/angular.js/commit/5c9399d18ae5cd79e6cf6fc4377d66df00f6fcc7), + [#15056](https://github.com/angular/angular.js/issues/15056)) +- **$controller:** throw when requested controller is not registered + ([9ae793](https://github.com/angular/angular.js/commit/9ae793d8a69afe84370b601e07fc375fc18a576a), + [#14980](https://github.com/angular/angular.js/issues/14980)) +- **$location:** add support for selectively rewriting links based on attribute + ([a4a222](https://github.com/angular/angular.js/commit/a4a22266f127d3b9a6818e6f4754f048e253f693)) +- **$resource:** pass `status`/`statusText` to success callbacks + ([a8da25](https://github.com/angular/angular.js/commit/a8da25c74d2c1f6265f0fafd95bf72c981d9d678), + [#8341](https://github.com/angular/angular.js/issues/8841), + [#8841](https://github.com/angular/angular.js/issues/8841)) +- **ngSwitch:** allow multiple case matches via optional attribute `ngSwitchWhenSeparator` + ([0e1651](https://github.com/angular/angular.js/commit/0e1651bfd28ba73ebd0e4943d85af48c4506e02c), + [#3410](https://github.com/angular/angular.js/issues/3410), + [#3516](https://github.com/angular/angular.js/issues/3516)) + + +## Performance Improvements +- **all:** don't trigger digests after enter/leave of structural directives + ([c57779](https://github.com/angular/angular.js/commit/c57779d8725493c5853dceda0105dafd5c0e3a7c), + [#15322](https://github.com/angular/angular.js/issues/15322)) +- **$compile:** validate `directive.restrict` property on directive init + ([31d464](https://github.com/angular/angular.js/commit/31d464feef38b1cc950da6c8dccd0f194ebfc68b)) +- **ngOptions:** avoid calls to `element.value` + ([e269ad](https://github.com/angular/angular.js/commit/e269ad1244bc50fee9218f7c18fab3e9ab063aab)) +- **jqLite:** move bind/unbind definitions out of the loop + ([7717b9](https://github.com/angular/angular.js/commit/7717b96e950a5916a5f12fd611c73d3b06a8d717)) + + + +# 1.6.0 rainbow-tsunami (2016-12-08) + +**Here are the full changes for the release of 1.6.0 that are not already released in the 1.5.x branch, +consolidating all the changes shown in the previous 1.6.0 release candidates.** + +## New Features +- **ngModelOptions:** allow options to be inherited from ancestor `ngModelOptions` + ([296cfc](https://github.com/angular/angular.js/commit/296cfce40c25e9438bfa46a0eb27240707a10ffa), + [#10922](https://github.com/angular/angular.js/issues/10922)) +- **$compile:** + - add `preAssignBindingsEnabled` option + ([dfb8cf](https://github.com/angular/angular.js/commit/dfb8cf6402678206132e5bc603764d21e0f986ef)) + - set `preAssignBindingsEnabled` to false by default + ([bcd0d4](https://github.com/angular/angular.js/commit/bcd0d4d896d0dfdd988ff4f849c1d40366125858), + [#15352](https://github.com/angular/angular.js/issues/15352)) + - throw error when directive name or factory function is invalid + ([53a3bf](https://github.com/angular/angular.js/commit/53a3bf6634600c3aeff092eacc35edf399b27aec) + [#15056](https://github.com/angular/angular.js/issues/15056)) +- **jqLite:** + - implement `jqLite(f)` as an alias to `jqLite(document).ready(f)` + ([369fb7](https://github.com/angular/angular.js/commit/369fb7f4f73664bcdab0350701552d8bef6f605e)) + - don't throw for elements with missing `getAttribute` + ([4e6c14](https://github.com/angular/angular.js/commit/4e6c14dcae4a9a30b3610a288ef8d20db47c4417)) + - don't get/set properties when getting/setting boolean attributes + ([7ceb5f](https://github.com/angular/angular.js/commit/7ceb5f6fcc43d35d1b66c3151ce6a71c60309304), + [#14126](https://github.com/angular/angular.js/issues/14126)) + - don't remove a boolean attribute for `.attr(attrName, '')` + ([3faf45](https://github.com/angular/angular.js/commit/3faf4505732758165083c9d21de71fa9b6983f4a)) + - remove the attribute for `.attr(attribute, null)` + ([4e3624](https://github.com/angular/angular.js/commit/4e3624552284d0e725bf6262b2e468cd2c7682fa)) + - return `[]` for `.val()` on ` + + + +``` + +The migration strategy is to convert values that matched with non-strict +conversion so that they will match with strict conversion. + + +- **feat(ngModelOptions): allow options to be inherited from ancestor `ngModelOptions` + ([296cfc](https://github.com/angular/angular.js/commit/296cfce40c25e9438bfa46a0eb27240707a10ffa))**: + +The programmatic API for `ngModelOptions` has changed. You must now read options +via the `ngModelController.$options.getOption(name)` method, rather than accessing the +option directly as a property of the `ngModelContoller.$options` object. This does not +affect the usage in templates and only affects custom directives that might have been +reading options for their own purposes. + +One benefit of these changes, though, is that the `ngModelControler.$options` property +is now guaranteed to be defined so there is no need to check before accessing. + +So, previously: + +``` +var myOption = ngModelController.$options && ngModelController.$options['my-option']; +``` + +and now: + +``` +var myOption = ngModelController.$options.getOption('my-option'); +``` + +### **jqLite** due to: +- **[fc0c11](https://github.com/angular/angular.js/commit/fc0c11db845d53061430b7f05e773dcb3fb5b860)**: + camelCase keys in `jqLite#data` + +Previously, keys passed to the data method were left untouched. +Now they are internally camelCased similarly to how jQuery handles it, i.e. +only single (!) hyphens followed by a lowercase letter get converted to an +uppercase letter. This means keys `a-b` and `aB` represent the same data piece; +writing to one of them will also be reflected if you ask for the other one. + +If you use Angular with jQuery, it already behaved in this way so no changes +are required on your part. + +To migrate the code follow the examples below: + +BEFORE: + +```js +/* 1 */ +elem.data('my-key', 2); +elem.data('myKey', 3); + +/* 2 */ +elem.data('foo-bar', 42); +elem.data()['foo-bar']; // 42 +elem.data()['fooBar']; // undefined + +/* 3 */ +elem.data()['foo-bar'] = 1; +elem.data()['fooBar'] = 2; +elem.data('foo-bar'); // 1 +``` + +AFTER: + +```js +/* 1 */ +// Rename one of the keys as they would now map to the same data slot. +elem.data('my-key', 2); +elem.data('my-key2', 3); + +/* 2 */ +elem.data('foo-bar', 42); +elem.data()['foo-bar']; // undefined +elem.data()['fooBar']; // 42 + +/* 3 */ +elem.data()['foo-bar'] = 1; +elem.data()['fooBar'] = 2; +elem.data('foo-bar'); // 2 +``` + +- **[73050c](https://github.com/angular/angular.js/commit/73050cdda04675bfa6705dc841ddbbb6919eb048)**: + align jqLite camelCasing logic with JQuery + +Before, when Angular was used without jQuery, the key passed +to the css method was more heavily camelCased; now only a single (!) hyphen +followed by a lowercase letter is getting transformed. This also affects APIs +that rely on the css method, like ngStyle. + +If you use Angular with jQuery, it already behaved in this way so no changes +are needed on your part. + +To migrate the code follow the example below: + +Before: + +HTML: + +```html +// All five versions used to be equivalent. +
+
+
+
+
+``` + +JS: + +```js +// All five versions used to be equivalent. +elem.css('background_color', 'blue'); +elem.css('background:color', 'blue'); +elem.css('background-color', 'blue'); +elem.css('background--color', 'blue'); +elem.css('backgroundColor', 'blue'); + +// All five versions used to be equivalent. +var bgColor = elem.css('background_color'); +var bgColor = elem.css('background:color'); +var bgColor = elem.css('background-color'); +var bgColor = elem.css('background--color'); +var bgColor = elem.css('backgroundColor'); +``` + +After: + +HTML: + +```html +// Previous five versions are no longer equivalent but these two still are. +
+
+``` + +JS: + +```js +// Previous five versions are no longer equivalent but these two still are. +elem.css('background-color', 'blue'); +elem.css('backgroundColor', 'blue'); + +// Previous five versions are no longer equivalent but these two still are. +var bgColor = elem.css('background-color'); +var bgColor = elem.css('backgroundColor'); +``` + +- **[7ceb5f](https://github.com/angular/angular.js/commit/7ceb5f6fcc43d35d1b66c3151ce6a71c60309304)**: don't get/set properties when getting/setting boolean attributes + +Previously, all boolean attributes were reflected into the corresponding property when calling a +setter and from the corresponding property when calling a getter, even on elements that don't treat +those attributes in a special way. Now Angular doesn't do it by itself, but relies on browsers to +know when to reflect the property. Note that this browser-level conversion differs between browsers; +if you need to dynamically change the state of an element, you should modify the property, not the +attribute. See https://jquery.com/upgrade-guide/1.9/#attr-versus-prop- for a more detailed +description about a related change in jQuery 1.9. + +This change aligns jqLite with jQuery 3. To migrate the code follow the example below: + +Before: + +CSS: + +```css +input[checked="checked"] { ... } +``` + +JS: + +```js +elem1.attr('checked', 'checked'); +elem2.attr('checked', false); +``` + +After: + +CSS: + +```css +input:checked { ... } +``` + +JS: + +```js +elem1.prop('checked', true); +elem2.prop('checked', false); +``` + +- **[3faf45](https://github.com/angular/angular.js/commit/3faf4505732758165083c9d21de71fa9b6983f4a)**: + don't remove a boolean attribute for `.attr(attrName, '')` + +Before, using the `attr` method with an empty string as a value +would remove the boolean attribute. Now it sets it to its lowercase name as +was happening for every non-empty string so far. The only two values that remove +the boolean attribute are now null & false, just like in jQuery. + +To migrate the code follow the example below: + +Before: + +```js +elem.attr(booleanAttrName, ''); +``` + +After: + +```js +elem.attr(booleanAttrName, false); +``` + +or: + +```js +elem.attr(booleanAttrName, null); +``` + +- **[4e3624](https://github.com/angular/angular.js/commit/4e3624552284d0e725bf6262b2e468cd2c7682fa)**: + remove the attribute for `.attr(attribute, null)` + +Invoking `elem.attr(attributeName, null)` would set the +`attributeName` attribute value to a string `"null"`, now it removes the +attribute instead. + +To migrate the code follow the example below: + +Before: + +```js +elem.attr(attributeName, null); +``` + +After: + +```js +elem.attr(attributeName, "null"); +``` + +- **[d882fd](https://github.com/angular/angular.js/commit/d882fde2e532216e7cf424495db1ccb5be1789f8)**: + return [] for .val() on ` + + + +``` + +JavaScript: + +```js + var value = $element.val(); + if (value) { + /* do something */ + } +``` + +After: + +HTML: + +```html + +``` + +JavaScript: + +```js + var value = $element.val(); + if (value.length > 0) { + /* do something */ + } +``` + + +### `ngModel` due to: + +- **[7bc71a](https://github.com/angular/angular.js/commit/7bc71adc63bb6bb609b44dd2d3ea8fb0cd3f300b)**: + treat synchronous validators as boolean always + +Previously, only a literal `false` return would resolve as the +synchronous validator failing. Now, all falsy JavaScript values +are treated as failing the validator, as one would naturally expect. + +Specifically, the values `0` (the number zero), `null`, `NaN` and `''` (the +empty string) used to be considered valid (passing) and they are now considered +invalid (failing). The value `undefined` was treated similarly to a pending +asynchronous validator, causing the validation to be pending. `undefined` is +also now considered invalid. + +To migrate, make sure your synchronous validators are returning either a +literal `true` or a literal `false` value. For most code, we expect this to +already be the case. Only a very small subset of projects will be affected. + +Namely, anyone using `undefined` or any falsy value as a return will now see +their validation failing, whereas previously falsy values other than `undefined` +would have been seen as passing and `undefined` would have been seen as pending. + +- **[9e24e7](https://github.com/angular/angular.js/commit/9e24e774a558143b3478536911a3a4c1714564ba)**: + change controllers to use prototype methods + +The use of prototype methods instead of new methods per instance removes the ability to pass +NgModelController and FormController methods without context. + +For example + +```js +$scope.$watch('something', myNgModelCtrl.$render) +``` + +will no longer work because the `$render` method is passed without any context. +This must now be replaced with + +```js +$scope.$watch('something', function() { + myNgModelCtrl.$render(); +}) +``` + +or possibly by using `Function.prototype.bind` or `angular.bind`. + + +### `aria/ngModel` due to: + +- **[975a61](https://github.com/angular/angular.js/commit/975a6170efceb2a5e6377c57329731c0636eb8c8)**: + do not overwrite the default `$isEmpty()` method for checkboxes + +Custom `checkbox`-shaped controls (e.g. checkboxes, menuitemcheckboxes), no longer have a custom +`$isEmpty()` method on their `NgModelController` that checks for `value === false`. Unless +overwritten, the default `$isEmpty()` method will be used, which treats `undefined`, `null`, `NaN` +and `''` as "empty". + +**Note:** The `$isEmpty()` method is used to determine if the checkbox is checked ("not empty" means + "checked") and thus it can indirectly affect other things, such as the control's validity + with respect to the `required` validator (e.g. "empty" + "required" --> "invalid"). + +Before: + +```js +var template = ''; +var customCheckbox = $compile(template)(scope); +var ctrl = customCheckbox.controller('ngModel'); + +scope.$apply('value = false'); +console.log(ctrl.$isEmpty()); //--> true + +scope.$apply('value = true'); +console.log(ctrl.$isEmpty()); //--> false + +scope.$apply('value = undefined'/* or null or NaN or '' */); +console.log(ctrl.$isEmpty()); //--> false +``` + +After: + +```js +var template = ''; +var customCheckbox = $compile(template)(scope); +var ctrl = customCheckbox.controller('ngModel'); + +scope.$apply('value = false'); +console.log(ctrl.$isEmpty()); //--> false + +scope.$apply('value = true'); +console.log(ctrl.$isEmpty()); //--> false + +scope.$apply('value = undefined'/* or null or NaN or '' */); +console.log(ctrl.$isEmpty()); //--> true +``` + +-- +If you want to have a custom `$isEmpty()` method, you need to overwrite the default. For example: + +```js +.directive('myCheckbox', function myCheckboxDirective() { + return { + require: 'ngModel', + link: function myCheckboxPostLink(scope, elem, attrs, ngModelCtrl) { + ngModelCtrl.$isEmpty = function myCheckboxIsEmpty(value) { + return !value; // Any falsy value means "empty" + + // Or to restore the previous behavior: + // return value === false; + }; + } + }; +}) +``` + +### `$http` due to: +- **[b54a39](https://github.com/angular/angular.js/commit/b54a39e2029005e0572fbd2ac0e8f6a4e5d69014)**: + remove deprecated callback methods: `success()/error()` + +`$http`'s deprecated custom callback methods - `success()` and `error()` - have been removed. +You can use the standard `then()`/`catch()` promise methods instead, but note that the method +signatures and return values are different. + +`success(fn)` can be replaced with `then(fn)`, and `error(fn)` can be replaced with either +`then(null, fn)` or `catch(fn)`. + +Before: + +```js +$http(...). + success(function onSuccess(data, status, headers, config) { + // Handle success + ... + }). + error(function onError(data, status, headers, config) { + // Handle error + ... + }); +``` + +After: + +```js +$http(...). + then(function onSuccess(response) { + // Handle success + var data = response.data; + var status = response.status; + var statusText = response.statusText; + var headers = response.headers; + var config = response.config; + ... + }, function onError(response) { + // Handle error + var data = response.data; + var status = response.status; + var statusText = response.statusText; + var headers = response.headers; + var config = response.config; + ... + }); + +// or + +$http(...). + then(function onSuccess(response) { + // Handle success + var data = response.data; + var status = response.status; + var statusText = response.statusText; + var headers = response.headers; + var config = response.config; + ... + }). + catch(function onError(response) { + // Handle error + var data = response.data; + var status = response.status; + var statusText = response.statusText; + var headers = response.headers; + var config = response.config; + ... + }); +``` + +**Note:** +There is a subtle difference between the variations showed above. When using +`$http(...).success(onSuccess).error(onError)` or `$http(...).then(onSuccess, onError)`, the +`onError()` callback will only handle errors/rejections produced by the `$http()` call. If the +`onSuccess()` callback produces an error/rejection, it won't be handled by `onError()` and might go +unnoticed. In contrast, when using `$http(...).then(onSuccess).catch(onError)`, `onError()` will +handle errors/rejections produced by both `$http()` _and_ `onSuccess()`. + +- **[fb6634](https://github.com/angular/angular.js/commit/fb663418710736161a6b5da49c345e92edf58dcb)**: + JSONP callback must be specified by `jsonpCallbackParam` config + +You can no longer use the `JSON_CALLBACK` placeholder in your JSONP requests. +Instead you must provide the name of the query parameter that will pass the +callback via the `jsonpCallbackParam` property of the config object, or app-wide via +the `$http.defaults.jsonpCallbackParam` property, which is `"callback"` by default. + +Before this change: + +```js +$http.json('trusted/url?callback=JSON_CALLBACK'); +$http.json('other/trusted/url', {params: {cb:'JSON_CALLBACK'}}); +``` + +After this change: + +```js +$http.json('trusted/url'); +$http.json('other/trusted/url', {jsonpCallbackParam:'cb'}); +``` + +- **[6476af](https://github.com/angular/angular.js/commit/6476af83cd0418c84e034a955b12a842794385c4)**: + JSONP requests now require a trusted resource URL + +All JSONP requests now require the URL to be trusted as resource URLs. +There are two approaches to trust a URL: + +**Whitelisting with the `$sceDelegateProvider.resourceUrlWhitelist()` +method.** + +You configure this list in a module configuration block: + +```js +appModule.config(['$sceDelegateProvider', function($sceDelegateProvider) { + $sceDelegateProvider.resourceUrlWhitelist([ + // Allow same origin resource loads. + 'self', + // Allow JSONP calls that match this pattern + 'https://some.dataserver.com/**.jsonp?**' + ]); +}]); +``` + +**Explicitly trusting the URL via the `$sce.trustAsResourceUrl(url)` +method.** + +You can pass a trusted object instead of a string as a URL to the `$http` +service: + +```js +var promise = $http.jsonp($sce.trustAsResourceUrl(url)); +``` + +- **[4f6f2b](https://github.com/angular/angular.js/commit/4f6f2bce4ac93b85320e42e5023c09d099779b7d)**: + properly increment/decrement `$browser.outstandingRequestCount` + +HTTP requests now update the outstanding request count synchronously. +Previously the request count would not have been updated until the +request to the server is actually in flight. Now the request count is +updated before the async interceptor is called. + +The new behaviour is correct but it may change the expected behaviour in +a small number of e2e test cases where an async request interceptor is +being used. + + +### `$q` due to: + +- **[e13eea](https://github.com/angular/angular.js/commit/e13eeabd7e34a78becec06cfbe72c23f2dcb85f9)**: + treat thrown errors as regular rejections + +Previously, throwing an error from a promise's `onFulfilled` or `onRejection` handlers, would result +in passing the error to the `$exceptionHandler()` (in addition to rejecting the promise with the +error as reason). + +Now, a thrown error is treated exactly the same as a regular rejection. This applies to all +services/controllers/filters etc that rely on `$q` (including built-in services, such as `$http` and +`$route`). For example, `$http`'s `transformRequest/Response` functions or a route's `redirectTo` +function as well as functions specified in a route's `resolve` object, will no longer result in a +call to `$exceptionHandler()` if they throw an error. Other than that, everything will continue to +behave in the same way; i.e. the promises will be rejected, route transition will be cancelled, +`$routeChangeError` events will be broadcasted etc. + +- **[c9dffd](https://github.com/angular/angular.js/commit/c9dffde1cb167660120753181cb6d01dc1d1b3d0)**: + report promises with non rejection callback + +Unhandled rejected promises will be logged to $exceptionHandler. + +Tests that depend on specific order or number of messages in $exceptionHandler +will need to handle rejected promises report. + + +### `ngTransclude` due to: + +- **[32aa7e](https://github.com/angular/angular.js/commit/32aa7e7395527624119e3917c54ee43b4d219301)**: + use fallback content if only whitespace is provided + +Previously whitespace only transclusion would be treated as the transclusion +being "not empty", which meant that fallback content was not used in that +case. + +Now if you only provide whitespace as the transclusion content, it will be +assumed to be empty and the fallback content will be used instead. + +If you really do want whitespace then you can force it to be used by adding +a comment to the whitespace. + +Previously this would not fallback to default content: + +```html + + +``` + +Now the whitespace between the opening and closing tags is treated as empty. To force the +previous behaviour simply add a comment: + +```html + + +``` + + +### `$compile` due to: + +- **[13c252](https://github.com/angular/angular.js/commit/13c2522baf7c8f616b2efcaab4bffd54c8736591)**: + correctly merge consecutive text nodes on IE11 + +**Note:** Everything described below affects **IE11 only**. + +Previously, consecutive text nodes would not get merged if they had no parent. They will now, which +might have unexpected side effects in the following cases: + +1. Passing an array or jqLite/jQuery collection of parent-less text nodes to `$compile` directly: + + ```js + // Assuming: + var textNodes = [ + document.createTextNode('{{'), + document.createTextNode('"foo:"'), + document.createTextNode('}}') + ]; + var compiledNodes = $compile(textNodes)($rootScope); + + // Before: + console.log(compiledNodes.length); // 3 + console.log(compiledNodes.text()); // {{'foo'}} + + // After: + console.log(compiledNodes.length); // 1 + console.log(compiledNodes.text()); // foo + + // To get the old behavior, compile each node separately: + var textNodes = [ + document.createTextNode('{{'), + document.createTextNode('"foo"'), + document.createTextNode('}}') + ]; + var compiledNodes = angular.element(textNodes.map(function (node) { + return $compile(node)($rootScope)[0]; + })); + ``` + +2. Using multi-slot transclusion with non-consecutive, default-content text nodes (that form + interpolated expressions when merged): + + ```js + // Assuming the following component: + .component('someThing', { + template: '' + transclude: { + ignored: 'veryImportantContent' + } + }) + ``` + + ```html + + + {{ + Nooot + 'foo'}} + + + + + + {{ <-- Two separate + 'foo'}} <-- text nodes + + + + + + + foo <-- The text nodes were merged into `{{'foo'}}`, which was then interpolated + + + + + + + {{ + Nooot + 'foo'}} + + + + + + {{ <-- Two separate + 'foo'}} <-- nodes + + + ``` + +- **[b89c21](https://github.com/angular/angular.js/commit/b89c2181a9a165e06c027390164e08635ec449f4)**: + move check for interpolation of `on-"event"` attributes to compile time + +Using interpolation in any on* event attributes (e.g. `' + - '' + - '
'); - - var form = doc.find('form'), - destroyed = false, - nextTurn = false, - submitted = false, - reloadPrevented; - - scope.destroy = function() { - // yes, I know, scope methods should not do direct DOM manipulation, but I wanted to keep - // this test small. Imagine that the destroy action will cause a model change (e.g. - // $location change) that will cause some directive to destroy the dom (e.g. ngView+$route) - doc.empty(); - destroyed = true; - }; - - scope.submitMe = function() { - submitted = true; - }; - - var assertPreventDefaultListener = function(e) { - reloadPrevented = e.defaultPrevented || (e.returnValue === false); - }; - - $compile(doc)(scope); - - addEventListenerFn(form[0], 'submit', assertPreventDefaultListener); - - browserTrigger(doc.find('button'), 'click'); - - // let the browser process all events (and potentially reload the page) - setTimeout(function() { nextTurn = true;}, 100); - - waitsFor(function() { return nextTurn; }); - - runs(function() { - expect(doc.html()).toBe(''); - expect(destroyed).toBe(true); - expect(submitted).toBe(false); // this is known corner-case that is not currently handled - // the issue is that the submit listener is destroyed before - // the event propagates there. we can fix this if we see - // the issue in the wild, I'm not going to bother to do it - // now. (i) - - // prevent mem leak in test - removeEventListenerFn(form[0], 'submit', assertPreventDefaultListener); + it('should prevent the default when the form is destroyed by a submission via a click event', function(done) { + inject(function($timeout) { + doc = jqLite('
' + + '
' + + '' + + '
' + + '
'); + // Support: Chrome 60+ (on Windows) + // We need to add the form to the DOM in order for `submit` events to be properly fired. + window.document.body.appendChild(doc[0]); + + var form = doc.find('form'), + destroyed = false, + nextTurn = false, + submitted = false, + reloadPrevented = 'never called'; + + scope.destroy = function() { + // yes, I know, scope methods should not do direct DOM manipulation, but I wanted to keep + // this test small. Imagine that the destroy action will cause a model change (e.g. + // $location change) that will cause some directive to destroy the dom (e.g. ngView+$route) + doc.empty(); + destroyed = true; + }; + + scope.submitMe = function() { + submitted = true; + }; + + var assertPreventDefaultListener = function(e) { + reloadPrevented = e.defaultPrevented || (e.returnValue === false); + }; + + $compile(doc)(scope); + + form[0].addEventListener('submit', assertPreventDefaultListener); + + browserTrigger(doc.find('button'), 'click'); + + // let the browser process all events (and potentially reload the page) + window.setTimeout(function() { nextTurn = true;}, 100); + + var job = createAsync(done); + job.waitsFor(function() { return nextTurn; }) + .runs(function() { + expect(doc.html()).toBe(''); + expect(destroyed).toBe(true); + expect(submitted).toBe(false); // this is known corner-case that is not currently handled + // the issue is that the submit listener is destroyed before + // the event propagates there. we can fix this if we see + // the issue in the wild, I'm not going to bother to do it + // now. (i) + + // Support: Chrome 60+ (on Windows) + // Chrome 60+ on Windows does not fire `submit` events when the form is not attached to + // the DOM. Verify that the `submit` listener was either never fired or (if fired) the + // reload was prevented. + expect(reloadPrevented).not.toBe(false); + + // prevent mem leak in test + form[0].removeEventListener('submit', assertPreventDefaultListener); + }) + .done(); + job.start(); }); - })); + }); it('should NOT prevent form submission if action attribute present', function() { - var callback = jasmine.createSpy('submit').andCallFake(function(event) { + var callback = jasmine.createSpy('submit').and.callFake(function(event) { expect(event.isDefaultPrevented()).toBe(false); event.preventDefault(); }); @@ -410,6 +539,113 @@ describe('form', function() { expect(parent.$submitted).toBeTruthy(); }); + it('should set $submitted to true on child forms when parent is submitted', function() { + doc = jqLite( + '' + + '' + + '' + + '' + + '' + + ''); + $compile(doc)(scope); + + var parent = scope.parent, + child = scope.child; + + parent.$setSubmitted(); + expect(parent.$submitted).toBeTruthy(); + expect(child.$submitted).toBeTruthy(); + }); + + + it('should not propagate $submitted state on removed child forms when parent is submitted', function() { + doc = jqLite( + '' + + '' + + '' + + '' + + '' + + '' + + ''); + $compile(doc)(scope); + + var parent = scope.parent, + child = scope.child, + grandchild = scope.grandchild, + ggchild = scope.greatgrandchild; + + parent.$removeControl(child); + + parent.$setSubmitted(); + expect(parent.$submitted).toBeTruthy(); + expect(child.$submitted).not.toBeTruthy(); + expect(grandchild.$submitted).not.toBeTruthy(); + + parent.$addControl(child); + + expect(parent.$submitted).toBeTruthy(); + expect(child.$submitted).not.toBeTruthy(); + expect(grandchild.$submitted).not.toBeTruthy(); + + parent.$setSubmitted(); + expect(parent.$submitted).toBeTruthy(); + expect(child.$submitted).toBeTruthy(); + expect(grandchild.$submitted).toBeTruthy(); + + parent.$removeControl(child); + + expect(parent.$submitted).toBeTruthy(); + expect(child.$submitted).toBeTruthy(); + expect(grandchild.$submitted).toBeTruthy(); + + parent.$setPristine(); // sets $submitted to false + expect(parent.$submitted).not.toBeTruthy(); + expect(child.$submitted).toBeTruthy(); + expect(grandchild.$submitted).toBeTruthy(); + + grandchild.$setPristine(); + expect(grandchild.$submitted).not.toBeTruthy(); + + child.$setSubmitted(); + expect(parent.$submitted).not.toBeTruthy(); + expect(child.$submitted).toBeTruthy(); + expect(grandchild.$submitted).toBeTruthy(); + + child.$setPristine(); + expect(parent.$submitted).not.toBeTruthy(); + expect(child.$submitted).not.toBeTruthy(); + expect(grandchild.$submitted).not.toBeTruthy(); + + // Test upwards submission setting + grandchild.$setSubmitted(); + expect(parent.$submitted).not.toBeTruthy(); + expect(child.$submitted).toBeTruthy(); + expect(grandchild.$submitted).toBeTruthy(); + }); + + + it('should set $submitted to true on child and parent forms when form is submitted', function() { + doc = jqLite( + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''); + $compile(doc)(scope); + + var parent = scope.parent, + child = scope.child, + grandchild = scope.grandchild; + + child.$setSubmitted(); + + expect(parent.$submitted).toBeTruthy(); + expect(child.$submitted).toBeTruthy(); + expect(grandchild.$submitted).toBeTruthy(); + }); it('should deregister a child form when its DOM is removed', function() { doc = jqLite( @@ -547,35 +783,153 @@ describe('form', function() { expect(doc.find('div').hasClass('ng-pending')).toBe(false); }); - it('should leave the parent form invalid when deregister a removed input', function() { - doc = jqLite( - '
' + - '
' + - '' + - '' + - '
' + - '
'); - $compile(doc)(scope); - scope.inputPresent = true; - scope.$apply(); - var parent = scope.parent, - child = scope.child, - inputA = child.inputA, - inputB = child.inputB; + it('should leave the parent form invalid when deregister a removed input', function() { + doc = jqLite( + '
' + + '
' + + '' + + '' + + '
' + + '
'); + $compile(doc)(scope); + scope.inputPresent = true; + scope.$apply(); - expect(parent).toBeDefined(); - expect(child).toBeDefined(); - expect(parent.$error.required).toEqual([child]); - expect(child.$error.required).toEqual([inputB, inputA]); + var parent = scope.parent, + child = scope.child, + inputA = child.inputA, + inputB = child.inputB; - //remove child input - scope.inputPresent = false; - scope.$apply(); + expect(parent).toBeDefined(); + expect(child).toBeDefined(); + expect(parent.$error.required).toEqual([child]); + expect(child.$error.required).toEqual([inputB, inputA]); + + //remove child input + scope.inputPresent = false; + scope.$apply(); + + expect(parent.$error.required).toEqual([child]); + expect(child.$error.required).toEqual([inputB]); + }); + + + it('should ignore changes in manually removed child forms', function() { + doc = $compile( + '
' + + '' + + '' + + '' + + '
')(scope); + + var form = scope.myForm; + var childformController = doc.find('ng-form').eq(0).controller('form'); + + var input = doc.find('input').eq(0); + var inputController = input.controller('ngModel'); + + changeInputValue(input, 'ab'); + scope.$apply(); + + expect(form.$dirty).toBe(true); + expect(form.$error.maxlength).toBeTruthy(); + expect(form.$error.maxlength[0].$name).toBe('childform'); + + inputController.$setPristine(); + expect(form.$dirty).toBe(true); + + form.$setPristine(); + + // remove child form + form.$removeControl(childformController); + expect(form.childform).toBeUndefined(); + expect(form.$error.maxlength).toBeFalsy(); + + changeInputValue(input, 'abc'); + scope.$apply(); + + expect(form.$error.maxlength).toBeFalsy(); + expect(form.$dirty).toBe(false); + }); + + + it('should react to changes in manually added child forms', function() { + doc = $compile( + '
' + + '' + + '' + + '' + + '
')(scope); + + var form = scope.myForm; + var childFormController = doc.find('ng-form').eq(0).controller('form'); + + var input = doc.find('input').eq(0); + + // remove child form so we can add it manually + form.$removeControl(childFormController); + changeInputValue(input, 'ab'); + + expect(form.childForm).toBeUndefined(); + expect(form.$dirty).toBe(false); + expect(form.$error.maxlength).toBeFalsy(); + + // re-add the child form; its current validation state is not propagated + form.$addControl(childFormController); + expect(form.childForm).toBe(childFormController); + expect(form.$error.maxlength).toBeFalsy(); + expect(form.$dirty).toBe(false); + + // Only when the input inside the child form changes, the validation state is propagated + changeInputValue(input, 'abc'); + expect(form.$error.maxlength[0]).toBe(childFormController); + expect(form.$dirty).toBe(false); + }); + + + it('should use the correct parent when renaming and removing dynamically added forms', function() { + scope.formName = 'childForm'; + scope.hasChildForm = true; + + doc = $compile( + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + '
')(scope); + + scope.$digest(); + + var form = scope.myForm; + var otherForm = scope.otherForm; + var childForm = form.childForm; + + // remove child form and add it to another form + form.$removeControl(childForm); + otherForm.$addControl(childForm); + + expect(form.childForm).toBeUndefined(); + expect(otherForm.childForm).toBe(childForm); + + // rename the childForm + scope.formName = 'childFormMoved'; + scope.$digest(); + + expect(form.childFormMoved).toBeUndefined(); + expect(otherForm.childForm).toBeUndefined(); + expect(otherForm.childFormMoved).toBe(childForm); + + scope.hasChildForm = false; + scope.$digest(); + + expect(form.childFormMoved).toBeUndefined(); + expect(otherForm.childFormMoved).toBeUndefined(); + }); - expect(parent.$error.required).toEqual([child]); - expect(child.$error.required).toEqual([inputB]); - }); it('should chain nested forms in repeater', function() { doc = jqLite( @@ -654,7 +1008,7 @@ describe('form', function() { expect(doc.hasClass('ng-valid-another')).toBe(true); expect(doc.hasClass('ng-invalid-another')).toBe(false); - // validators are skipped, e.g. becuase of a parser error + // validators are skipped, e.g. because of a parser error control.$setValidity('error', null); control.$setValidity('another', null); scope.$digest(); @@ -802,6 +1156,7 @@ describe('form', function() { scope.$digest(); expect(form).toBePristine(); scope.$digest(); + expect(formCtrl.$pristine).toBe(true); expect(formCtrl.$dirty).toBe(false); expect(nestedForm).toBePristine(); @@ -845,6 +1200,52 @@ describe('form', function() { }); }); + describe('$getControls', function() { + it('should return an empty array if the controller has no controls', function() { + doc = $compile('
')(scope); + + scope.$digest(); + + var formCtrl = scope.testForm; + + expect(formCtrl.$getControls()).toEqual([]); + }); + + it('should return a shallow copy of the form controls', function() { + doc = $compile( + '
' + + '' + + '
' + + '' + + '
' + + '
')(scope); + + scope.$digest(); + + var form = doc, + formCtrl = scope.testForm, + formInput = form.children('input').eq(0), + formInputCtrl = formInput.controller('ngModel'), + nestedForm = form.find('div'), + nestedFormCtrl = nestedForm.controller('form'), + nestedInput = nestedForm.children('input').eq(0), + nestedInputCtrl = nestedInput.controller('ngModel'); + + var controls = formCtrl.$getControls(); + + expect(controls).not.toBe(formCtrl.$$controls); + + controls.push('something'); + expect(formCtrl.$$controls).not.toContain('something'); + + expect(controls[0]).toBe(formInputCtrl); + expect(controls[1]).toBe(nestedFormCtrl); + + var nestedControls = controls[1].$getControls(); + + expect(nestedControls[0]).toBe(nestedInputCtrl); + }); + }); it('should rename nested form controls when interpolated name changes', function() { scope.idA = 'A'; @@ -856,7 +1257,7 @@ describe('form', function() { '
' + '
' + - '' )(scope); scope.$digest(); @@ -881,20 +1282,38 @@ describe('form', function() { it('should rename forms with no parent when interpolated name changes', function() { var element = $compile('
')(scope); - var element2 = $compile('
')(scope); - scope.nameID = "A"; + var element2 = $compile('
')(scope); + scope.nameID = 'A'; scope.$digest(); var form = element.controller('form'); var form2 = element2.controller('form'); + expect(scope.nameA).toBe(form); + expect(scope.ngformA).toBe(form2); expect(form.$name).toBe('nameA'); - expect(form2.$name).toBe('nameA'); + expect(form2.$name).toBe('ngformA'); - scope.nameID = "B"; + scope.nameID = 'B'; scope.$digest(); + expect(scope.nameA).toBeUndefined(); + expect(scope.ngformA).toBeUndefined(); + expect(scope.nameB).toBe(form); + expect(scope.ngformB).toBe(form2); expect(form.$name).toBe('nameB'); - expect(form2.$name).toBe('nameB'); + expect(form2.$name).toBe('ngformB'); }); + it('should rename forms with an initially blank name', function() { + var element = $compile('
')(scope); + scope.$digest(); + var form = element.controller('form'); + expect(scope['']).toBe(form); + expect(form.$name).toBe(''); + scope.name = 'foo'; + scope.$digest(); + expect(scope.foo).toBe(form); + expect(form.$name).toBe('foo'); + expect(scope.foo).toBe(form); + }); describe('$setSubmitted', function() { beforeEach(function() { diff --git a/test/ng/directive/inputSpec.js b/test/ng/directive/inputSpec.js index ad98d52abdf9..34ae2e127734 100644 --- a/test/ng/directive/inputSpec.js +++ b/test/ng/directive/inputSpec.js @@ -1,48 +1,49 @@ 'use strict'; -/* globals getInputCompileHelper: false */ +/* globals generateInputCompilerHelper: false */ describe('input', function() { - var helper, $compile, $rootScope, $browser, $sniffer, $timeout, $q; + var helper = {}, $compile, $rootScope, $browser, $sniffer; - beforeEach(function() { - helper = getInputCompileHelper(this); - }); - - afterEach(function() { - helper.dealoc(); - }); + // UA sniffing to exclude Edge from some date input tests + var isEdge = /\bEdge\//.test(window.navigator.userAgent); + generateInputCompilerHelper(helper); - beforeEach(inject(function(_$compile_, _$rootScope_, _$browser_, _$sniffer_, _$timeout_, _$q_) { + beforeEach(inject(function(_$compile_, _$rootScope_, _$browser_, _$sniffer_) { $compile = _$compile_; $rootScope = _$rootScope_; $browser = _$browser_; $sniffer = _$sniffer_; - $timeout = _$timeout_; - $q = _$q_; })); it('should bind to a model', function() { var inputElm = helper.compileInput(''); - $rootScope.$apply("name = 'misko'"); + $rootScope.$apply('name = \'misko\''); expect(inputElm.val()).toBe('misko'); }); it('should not set readonly or disabled property on ie7', function() { - this.addMatchers({ - toBeOff: function(attributeName) { - var actualValue = this.actual.attr(attributeName); - this.message = function() { - return "Attribute '" + attributeName + "' expected to be off but was '" + actualValue + - "' in: " + angular.mock.dump(this.actual); + jasmine.addMatchers({ + toBeOff: function() { + return { + compare: function(actual, attributeName) { + var actualValue = actual.attr(attributeName); + var message = function() { + return 'Attribute \'' + attributeName + '\' expected to be off but was \'' + actualValue + + '\' in: ' + angular.mock.dump(actual); + }; + + return { + pass: !actualValue || actualValue === 'false', + message: message + }; + } }; - - return !actualValue || actualValue == 'false'; } }); @@ -72,6 +73,25 @@ describe('input', function() { expect($rootScope.form.$$renameControl).not.toHaveBeenCalled(); }); + + it('should not set the `val` property when the value is equal to the current value', inject(function($rootScope, $compile) { + // This is a workaround for Firefox validation. Look at #12102. + var input = jqLite(''); + var setterCalls = 0; + $rootScope.foo = ''; + Object.defineProperty(input[0], 'value', { + get: function() { + return ''; + }, + set: function() { + setterCalls++; + } + }); + $compile(input)($rootScope); + $rootScope.$digest(); + expect(setterCalls).toBe(0); + })); + describe('compositionevents', function() { it('should not update the model between "compositionstart" and "compositionend" on non android', function() { @@ -112,37 +132,60 @@ describe('input', function() { browserTrigger(inputElm, 'compositionend'); expect($rootScope.name).toEqual('caitp'); }); + + + it('should end composition on "compositionupdate" when event.data is ""', function() { + // This tests a bug workaround for IE9-11 + // During composition, when an input is de-focussed by clicking away from it, + // the compositionupdate event is called with '', followed by a change event. + var inputElm = helper.compileInput(''); + browserTrigger(inputElm, 'compositionstart'); + helper.changeInputValueTo('caitp'); + expect($rootScope.name).toBeUndefined(); + browserTrigger(inputElm, 'compositionupdate', {data: ''}); + browserTrigger(inputElm, 'change'); + expect($rootScope.name).toEqual('caitp'); + }); }); - describe("IE placeholder input events", function() { + describe('IE placeholder input events', function() { + // Support: IE 9-11 only //IE fires an input event whenever a placeholder visually changes, essentially treating it as a value //Events: // placeholder attribute change: *input* // focus (which visually removes the placeholder value): focusin focus *input* // blur (which visually creates the placeholder value): focusout *input* blur //However none of these occur if the placeholder is not visible at the time of the event. - //These tests try simulate various scenerios which do/do-not fire the extra input event + //These tests try simulate various scenarios which do/do-not fire the extra input event it('should not dirty the model on an input event in response to a placeholder change', function() { var inputElm = helper.compileInput(''); - msie && browserTrigger(inputElm, 'input'); + if (msie) { + browserTrigger(inputElm, 'input'); + } expect(inputElm.attr('placeholder')).toBe('Test'); expect(inputElm).toBePristine(); helper.attrs.$set('placeholder', ''); - msie && browserTrigger(inputElm, 'input'); + if (msie) { + browserTrigger(inputElm, 'input'); + } expect(inputElm.attr('placeholder')).toBe(''); expect(inputElm).toBePristine(); helper.attrs.$set('placeholder', 'Test Again'); - msie && browserTrigger(inputElm, 'input'); + if (msie) { + browserTrigger(inputElm, 'input'); + } expect(inputElm.attr('placeholder')).toBe('Test Again'); expect(inputElm).toBePristine(); helper.attrs.$set('placeholder', undefined); - msie && browserTrigger(inputElm, 'input'); - expect(inputElm.attr('placeholder')).toBe(undefined); + if (msie) { + browserTrigger(inputElm, 'input'); + } + expect(inputElm.attr('placeholder')).toBeUndefined(); expect(inputElm).toBePristine(); helper.changeInputValueTo('foo'); @@ -152,17 +195,23 @@ describe('input', function() { it('should not dirty the model on an input event in response to a interpolated placeholder change', function() { var inputElm = helper.compileInput(''); - msie && browserTrigger(inputElm, 'input'); + if (msie) { + browserTrigger(inputElm, 'input'); + } expect(inputElm).toBePristine(); $rootScope.ph = 1; $rootScope.$digest(); - msie && browserTrigger(inputElm, 'input'); + if (msie) { + browserTrigger(inputElm, 'input'); + } expect(inputElm).toBePristine(); - $rootScope.ph = ""; + $rootScope.ph = ''; $rootScope.$digest(); - msie && browserTrigger(inputElm, 'input'); + if (msie) { + browserTrigger(inputElm, 'input'); + } expect(inputElm).toBePristine(); helper.changeInputValueTo('foo'); @@ -177,7 +226,9 @@ describe('input', function() { browserTrigger(inputElm, 'focusin'); browserTrigger(inputElm, 'focus'); - msie && browserTrigger(inputElm, 'input'); + if (msie) { + browserTrigger(inputElm, 'input'); + } expect(inputElm.attr('placeholder')).toBe('Test'); expect(inputElm).toBePristine(); @@ -196,12 +247,16 @@ describe('input', function() { $rootScope.ph = 1; $rootScope.$digest(); - msie && browserTrigger(inputElm, 'input'); + if (msie) { + browserTrigger(inputElm, 'input'); + } expect(inputElm).toBePristine(); - $rootScope.ph = ""; + $rootScope.ph = ''; $rootScope.$digest(); - msie && browserTrigger(inputElm, 'input'); + if (msie) { + browserTrigger(inputElm, 'input'); + } expect(inputElm).toBePristine(); helper.changeInputValueTo('foo'); @@ -211,13 +266,17 @@ describe('input', function() { it('should not dirty the model on an input event in response to a focus', function() { var inputElm = helper.compileInput(''); - msie && browserTrigger(inputElm, 'input'); + if (msie) { + browserTrigger(inputElm, 'input'); + } expect(inputElm.attr('placeholder')).toBe('Test'); expect(inputElm).toBePristine(); browserTrigger(inputElm, 'focusin'); browserTrigger(inputElm, 'focus'); - msie && browserTrigger(inputElm, 'input'); + if (msie) { + browserTrigger(inputElm, 'input'); + } expect(inputElm.attr('placeholder')).toBe('Test'); expect(inputElm).toBePristine(); @@ -228,17 +287,23 @@ describe('input', function() { it('should not dirty the model on an input event in response to a blur', function() { var inputElm = helper.compileInput(''); - msie && browserTrigger(inputElm, 'input'); + if (msie) { + browserTrigger(inputElm, 'input'); + } expect(inputElm.attr('placeholder')).toBe('Test'); expect(inputElm).toBePristine(); browserTrigger(inputElm, 'focusin'); browserTrigger(inputElm, 'focus'); - msie && browserTrigger(inputElm, 'input'); + if (msie) { + browserTrigger(inputElm, 'input'); + } expect(inputElm).toBePristine(); browserTrigger(inputElm, 'focusout'); - msie && browserTrigger(inputElm, 'input'); + if (msie) { + browserTrigger(inputElm, 'input'); + } browserTrigger(inputElm, 'blur'); expect(inputElm).toBePristine(); @@ -301,11 +366,11 @@ describe('input', function() { it('should rename form controls in form when interpolated name changes', function() { - $rootScope.nameID = "A"; + $rootScope.nameID = 'A'; var inputElm = helper.compileInput(''); expect($rootScope.form.nameA.$name).toBe('nameA'); var oldModel = $rootScope.form.nameA; - $rootScope.nameID = "B"; + $rootScope.nameID = 'B'; $rootScope.$digest(); expect($rootScope.form.nameA).toBeUndefined(); expect($rootScope.form.nameB).toBe(oldModel); @@ -314,12 +379,12 @@ describe('input', function() { it('should rename form controls in null form when interpolated name changes', function() { - $rootScope.nameID = "A"; + $rootScope.nameID = 'A'; var inputElm = helper.compileInput(''); var model = inputElm.controller('ngModel'); expect(model.$name).toBe('nameA'); - $rootScope.nameID = "B"; + $rootScope.nameID = 'B'; $rootScope.$digest(); expect(model.$name).toBe('nameB'); }); @@ -369,8 +434,7 @@ describe('input', function() { scope.field = 'fake field'; scope.$watch('field', function() { - // We need to use _originalTrigger since trigger is modified by Angular Scenario. - inputElm._originalTrigger('change'); + inputElm.trigger('change'); }); scope.$apply(); }; @@ -387,7 +451,7 @@ describe('input', function() { } }); - describe('"keydown", "paste" and "cut" events', function() { + describe('"keydown", "paste", "cut" and "drop" events', function() { beforeEach(function() { // Force browser to report a lack of an 'input' event $sniffer.hasEvent = function(eventName) { @@ -409,6 +473,18 @@ describe('input', function() { expect($rootScope.name).toEqual('mark'); }); + it('should update the model on "drop" event if the input value changes', function() { + var inputElm = helper.compileInput(''); + + browserTrigger(inputElm, 'keydown'); + $browser.defer.flush(); + expect(inputElm).toBePristine(); + + inputElm.val('mark'); + browserTrigger(inputElm, 'drop'); + $browser.defer.flush(); + expect($rootScope.name).toEqual('mark'); + }); it('should update the model on "cut" event', function() { var inputElm = helper.compileInput(''); @@ -460,7 +536,7 @@ describe('input', function() { it('should allow complex reference binding', function() { var inputElm = helper.compileInput(''); - $rootScope.$apply("obj = { abc: { name: 'Misko'} }"); + $rootScope.$apply('obj = { abc: { name: \'Misko\'} }'); expect(inputElm.val()).toEqual('Misko'); }); @@ -479,11 +555,11 @@ describe('input', function() { it('should report error on assignment error', function() { expect(function() { var inputElm = helper.compileInput(''); - }).toThrowMinErr("$parse", "syntax", "Syntax Error: Token '''' is an unexpected token at column 7 of the expression [throw ''] starting at ['']."); + }).toThrowMinErr('$parse', 'syntax', 'Syntax Error: Token \'\'\'\' is an unexpected token at column 7 of the expression [throw \'\'] starting at [\'\'].'); }); - it("should render as blank if null", function() { + it('should render as blank if null', function() { var inputElm = helper.compileInput(''); $rootScope.$apply('age = null'); @@ -551,16 +627,43 @@ describe('input', function() { expect(inputElm.val()).toBe('2013-01'); - try { - //set to text for browsers with datetime-local validation. - inputElm[0].setAttribute('type', 'text'); - } catch (e) { - //for IE8 - } + //set to text for browsers with datetime-local validation. + inputElm[0].setAttribute('type', 'text'); helper.changeInputValueTo('stuff'); expect(inputElm.val()).toBe('stuff'); expect($rootScope.value).toBeUndefined(); + expect(inputElm).toHaveClass('ng-invalid-month'); + expect(inputElm).toBeInvalid(); + }); + + + it('should not set error=month when a later parser returns undefined', function() { + var inputElm = helper.compileInput(''); + var ctrl = inputElm.controller('ngModel'); + + ctrl.$parsers.push(function() { + return undefined; + }); + + inputElm[0].setAttribute('type', 'text'); + + helper.changeInputValueTo('2017-01'); + + expect($rootScope.value).toBeUndefined(); + expect(ctrl.$error.month).toBeFalsy(); + expect(ctrl.$error.parse).toBeTruthy(); + expect(inputElm).not.toHaveClass('ng-invalid-month'); + expect(inputElm).toHaveClass('ng-invalid-parse'); + expect(inputElm).toBeInvalid(); + + helper.changeInputValueTo('asdf'); + + expect($rootScope.value).toBeUndefined(); + expect(ctrl.$error.month).toBeTruthy(); + expect(ctrl.$error.parse).toBeFalsy(); + expect(inputElm).toHaveClass('ng-invalid-month'); + expect(inputElm).not.toHaveClass('ng-invalid-parse'); expect(inputElm).toBeInvalid(); }); @@ -613,6 +716,39 @@ describe('input', function() { }); + it('should be possible to override the timezone', function() { + var inputElm = helper.compileInput(''); + + helper.changeInputValueTo('2013-07'); + expect(+$rootScope.value).toBe(Date.UTC(2013, 6, 1)); + + inputElm.controller('ngModel').$overrideModelOptions({timezone: '-0500'}); + + $rootScope.$apply(function() { + $rootScope.value = new Date(Date.UTC(2013, 6, 1)); + }); + expect(inputElm.val()).toBe('2013-06'); + }); + + + they('should use any timezone if specified in the options (format: $prop)', + {'+HHmm': '+0500', '+HH:mm': '+05:00'}, + function(tz) { + var ngModelOptions = '{timezone: \'' + tz + '\'}'; + var inputElm = helper.compileInput( + ''); + + helper.changeInputValueTo('2013-07'); + expect(+$rootScope.value).toBe(Date.UTC(2013, 5, 30, 19, 0, 0)); + + $rootScope.$apply(function() { + $rootScope.value = new Date(Date.UTC(2014, 5, 30, 19, 0, 0)); + }); + expect(inputElm.val()).toBe('2014-07'); + } + ); + + it('should label parse errors as `month`', function() { var inputElm = helper.compileInput('', { valid: false, @@ -625,6 +761,22 @@ describe('input', function() { }); + // Support: Edge 16 + // Edge does not support years with any number of digits other than 4. + if (!isEdge) { + it('should allow four or more digits in year', function() { + var inputElm = helper.compileInput(''); + + helper.changeInputValueTo('10123-03'); + expect(+$rootScope.value).toBe(Date.UTC(10123, 2, 1, 0, 0, 0)); + + $rootScope.$apply(function() { + $rootScope.value = new Date(Date.UTC(20456, 3, 1, 0, 0, 0)); + }); + expect(inputElm.val()).toBe('20456-04'); + }); + } + it('should only change the month of a bound date', function() { var inputElm = helper.compileInput(''); @@ -636,6 +788,17 @@ describe('input', function() { expect(inputElm.val()).toBe('2013-12'); }); + it('should only change the month of a bound date in any timezone', function() { + var inputElm = helper.compileInput(''); + + $rootScope.$apply(function() { + $rootScope.value = new Date(Date.UTC(2013, 6, 31, 20, 0, 0)); + }); + helper.changeInputValueTo('2013-09'); + expect(+$rootScope.value).toBe(Date.UTC(2013, 7, 31, 20, 0, 0)); + expect(inputElm.val()).toBe('2013-09'); + }); + describe('min', function() { var inputElm; beforeEach(function() { @@ -668,6 +831,30 @@ describe('input', function() { expect(inputElm).toBeInvalid(); expect($rootScope.form.alias.$error.min).toBeTruthy(); }); + + it('should validate if min is empty', function() { + $rootScope.minVal = undefined; + $rootScope.value = new Date(-9999, 0, 1, 0, 0, 0); + $rootScope.$digest(); + + expect($rootScope.form.alias.$error.min).toBeFalsy(); + }); + + it('should only validate once after compilation when inside ngRepeat', function() { + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.min).toBe(1); + + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.min).toBe(1); + }); }); describe('max', function() { @@ -702,6 +889,47 @@ describe('input', function() { expect(inputElm).toBeInvalid(); expect($rootScope.form.alias.$error.max).toBeTruthy(); }); + + it('should validate if max is empty', function() { + $rootScope.maxVal = undefined; + $rootScope.value = new Date(9999, 11, 31, 23, 59, 59); + $rootScope.$digest(); + + expect($rootScope.form.alias.$error.max).toBeFalsy(); + }); + + it('should validate when timezone is provided.', function() { + inputElm = helper.compileInput(''); + $rootScope.maxVal = '2013-01'; + $rootScope.value = new Date(Date.UTC(2013, 0, 1, 0, 0, 0)); + $rootScope.$digest(); + + expect($rootScope.form.alias.$error.max).toBeFalsy(); + expect($rootScope.form.alias.$valid).toBeTruthy(); + + $rootScope.value = ''; + helper.changeInputValueTo('2013-01'); + expect(inputElm).toBeValid(); + expect($rootScope.form.alias.$error.max).toBeFalsy(); + expect($rootScope.form.alias.$valid).toBeTruthy(); + }); + + it('should only validate once after compilation when inside ngRepeat', function() { + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.max).toBe(1); + + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.max).toBe(1); + }); }); }); @@ -752,12 +980,8 @@ describe('input', function() { expect(inputElm.val()).toBe('2013-W02'); - try { - //set to text for browsers with datetime-local validation. - inputElm[0].setAttribute('type', 'text'); - } catch (e) { - //for IE8 - } + //set to text for browsers with datetime-local validation. + inputElm[0].setAttribute('type', 'text'); helper.changeInputValueTo('stuff'); expect(inputElm.val()).toBe('stuff'); @@ -800,6 +1024,21 @@ describe('input', function() { expect(inputElm).toBeValid(); }); + // Support: Edge 16 + // Edge does not support years with any number of digits other than 4. + if (!isEdge) { + it('should allow four or more digits in year', function() { + var inputElm = helper.compileInput(''); + + helper.changeInputValueTo('10123-W03'); + expect(+$rootScope.value).toBe(Date.UTC(10123, 0, 21)); + + $rootScope.$apply(function() { + $rootScope.value = new Date(Date.UTC(20456, 0, 28)); + }); + expect(inputElm.val()).toBe('20456-W04'); + }); + } it('should use UTC if specified in the options', function() { var inputElm = helper.compileInput(''); @@ -814,6 +1053,48 @@ describe('input', function() { }); + it('should be possible to override the timezone', function() { + var inputElm = helper.compileInput(''); + + // January 19 2013 is a Saturday + $rootScope.$apply(function() { + $rootScope.value = new Date(Date.UTC(2013, 0, 19)); + }); + + expect(inputElm.val()).toBe('2013-W03'); + + inputElm.controller('ngModel').$overrideModelOptions({timezone: '+2400'}); + + // To check that the timezone overwrite works, apply an offset of +24 hours. + // Since January 19 is a Saturday, +24 will turn the formatted Date into January 20 - Sunday - + // which is in calendar week 4 instead of 3. + $rootScope.$apply(function() { + $rootScope.value = new Date(Date.UTC(2013, 0, 19)); + }); + + // Verifying that the displayed week is week 4 confirms that overriding the timezone worked + expect(inputElm.val()).toBe('2013-W04'); + }); + + + they('should use any timezone if specified in the options (format: $prop)', + {'+HHmm': '+0500', '+HH:mm': '+05:00'}, + function(tz) { + var ngModelOptions = '{timezone: \'' + tz + '\'}'; + var inputElm = helper.compileInput( + ''); + + helper.changeInputValueTo('2013-W03'); + expect(+$rootScope.value).toBe(Date.UTC(2013, 0, 16, 19, 0, 0)); + + $rootScope.$apply(function() { + $rootScope.value = new Date(Date.UTC(2014, 0, 16, 19, 0, 0)); + }); + expect(inputElm.val()).toBe('2014-W03'); + } + ); + + it('should label parse errors as `week`', function() { var inputElm = helper.compileInput('', { valid: false, @@ -857,6 +1138,30 @@ describe('input', function() { expect(inputElm).toBeInvalid(); expect($rootScope.form.alias.$error.min).toBeTruthy(); }); + + it('should validate if min is empty', function() { + $rootScope.minVal = undefined; + $rootScope.value = new Date(-9999, 0, 1, 0, 0, 0); + $rootScope.$digest(); + + expect($rootScope.form.alias.$error.min).toBeFalsy(); + }); + + it('should only validate once after compilation when inside ngRepeat', function() { + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.min).toBe(1); + + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.min).toBe(1); + }); }); describe('max', function() { @@ -892,6 +1197,49 @@ describe('input', function() { expect(inputElm).toBeInvalid(); expect($rootScope.form.alias.$error.max).toBeTruthy(); }); + + it('should validate if max is empty', function() { + $rootScope.maxVal = undefined; + $rootScope.value = new Date(9999, 11, 31, 23, 59, 59); + $rootScope.$digest(); + + expect($rootScope.form.alias.$error.max).toBeFalsy(); + }); + + it('should validate when timezone is provided.', function() { + inputElm = helper.compileInput(''); + // The calendar week comparison date is January 17. Setting the timezone to -2400 + // makes the January 18 date value valid. + $rootScope.maxVal = '2013-W03'; + $rootScope.value = new Date(Date.UTC(2013, 0, 18)); + $rootScope.$digest(); + + expect($rootScope.form.alias.$error.max).toBeFalsy(); + expect($rootScope.form.alias.$valid).toBeTruthy(); + + $rootScope.value = ''; + helper.changeInputValueTo('2013-W03'); + expect(inputElm).toBeValid(); + expect($rootScope.form.alias.$error.max).toBeFalsy(); + expect($rootScope.form.alias.$valid).toBeTruthy(); + }); + + it('should only validate once after compilation when inside ngRepeat', function() { + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.max).toBe(1); + + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.max).toBe(1); + }); }); }); @@ -923,17 +1271,13 @@ describe('input', function() { var inputElm = helper.compileInput(''); $rootScope.$apply(function() { - $rootScope.breakMe = new Date(2009, 0, 6, 16, 25, 0); + $rootScope.breakMe = new Date(2009, 0, 6, 16, 25, 1, 337); }); - expect(inputElm.val()).toBe('2009-01-06T16:25:00.000'); + expect(inputElm.val()).toBe('2009-01-06T16:25:01.337'); - try { - //set to text for browsers with datetime-local validation. - inputElm[0].setAttribute('type', 'text'); - } catch (e) { - //for IE8 - } + //set to text for browsers with datetime-local validation. + inputElm[0].setAttribute('type', 'text'); helper.changeInputValueTo('stuff'); expect(inputElm.val()).toBe('stuff'); @@ -980,13 +1324,61 @@ describe('input', function() { it('should use UTC if specified in the options', function() { var inputElm = helper.compileInput(''); - helper.changeInputValueTo('2000-01-01T01:02'); - expect(+$rootScope.value).toBe(Date.UTC(2000, 0, 1, 1, 2, 0)); + helper.changeInputValueTo('2000-01-01T01:02:03.456'); + expect(+$rootScope.value).toBe(Date.UTC(2000, 0, 1, 1, 2, 3, 456)); + + $rootScope.$apply(function() { + $rootScope.value = new Date(Date.UTC(2001, 0, 1, 1, 2, 3, 456)); + }); + expect(inputElm.val()).toBe('2001-01-01T01:02:03.456'); + }); + + it('should be possible to override the timezone', function() { + var inputElm = helper.compileInput(''); + + helper.changeInputValueTo('2000-01-01T01:02:03.456'); + expect(+$rootScope.value).toBe(Date.UTC(2000, 0, 1, 1, 2, 3, 456)); + + inputElm.controller('ngModel').$overrideModelOptions({timezone: '+0500'}); $rootScope.$apply(function() { - $rootScope.value = new Date(Date.UTC(2001, 0, 1, 1, 2, 0)); + $rootScope.value = new Date(Date.UTC(2001, 0, 1, 1, 2, 3, 456)); }); - expect(inputElm.val()).toBe('2001-01-01T01:02:00.000'); + expect(inputElm.val()).toBe('2001-01-01T06:02:03.456'); + + inputElm.controller('ngModel').$overrideModelOptions({timezone: 'UTC'}); + + helper.changeInputValueTo('2000-01-01T01:02:03.456'); + expect(+$rootScope.value).toBe(Date.UTC(2000, 0, 1, 1, 2, 3, 456)); + }); + + + they('should use any timezone if specified in the options (format: $prop)', + {'+HHmm': '+0500', '+HH:mm': '+05:00'}, + function(tz) { + var ngModelOptions = '{timezone: \'' + tz + '\'}'; + var inputElm = helper.compileInput( + ''); + + helper.changeInputValueTo('2000-01-01T06:02:03.456'); + expect(+$rootScope.value).toBe(Date.UTC(2000, 0, 1, 1, 2, 3, 456)); + + $rootScope.$apply(function() { + $rootScope.value = new Date(Date.UTC(2001, 0, 1, 1, 2, 3, 456)); + }); + expect(inputElm.val()).toBe('2001-01-01T06:02:03.456'); + } + ); + + + it('should fallback to default timezone in case an unknown timezone was passed', function() { + var inputElm = helper.compileInput( + '' + + ''); + + helper.changeGivenInputTo(inputElm.eq(0), '2000-01-01T06:02'); + helper.changeGivenInputTo(inputElm.eq(1), '2000-01-01T06:02'); + expect($rootScope.value1).toEqual($rootScope.value2); }); @@ -1009,13 +1401,13 @@ describe('input', function() { it('should allow to specify the seconds', function() { var inputElm = helper.compileInput(''); - helper.changeInputValueTo('2000-01-01T01:02:03'); - expect(+$rootScope.value).toBe(+new Date(2000, 0, 1, 1, 2, 3)); + helper.changeInputValueTo('2000-01-01T01:02:03.456'); + expect(+$rootScope.value).toBe(+new Date(2000, 0, 1, 1, 2, 3, 456)); $rootScope.$apply(function() { - $rootScope.value = new Date(2001, 0, 1, 1, 2, 3); + $rootScope.value = new Date(2001, 0, 1, 1, 2, 3, 456); }); - expect(inputElm.val()).toBe('2001-01-01T01:02:03.000'); + expect(inputElm.val()).toBe('2001-01-01T01:02:03.456'); }); @@ -1027,6 +1419,24 @@ describe('input', function() { }); + // Support: Edge 16 + // Edge does not support years with any number of digits other than 4. + if (!isEdge) { + it('should allow four or more digits in year', function() { + var inputElm = helper.compileInput(''); + + helper.changeInputValueTo('10123-01-01T01:02:03.456'); + expect(+$rootScope.value).toBe(+new Date(10123, 0, 1, 1, 2, 3, 456)); + + $rootScope.$apply(function() { + $rootScope.value = new Date(20456, 1, 1, 1, 2, 3, 456); + }); + expect(inputElm.val()).toBe('20456-02-01T01:02:03.456'); + } + ); + } + + it('should label parse errors as `datetimelocal`', function() { var inputElm = helper.compileInput('', { valid: false, @@ -1038,45 +1448,152 @@ describe('input', function() { expect($rootScope.form.alias.$error.datetimelocal).toBeTruthy(); }); - describe('min', function() { - var inputElm; - beforeEach(function() { - $rootScope.minVal = '2000-01-01T12:30:00'; - inputElm = helper.compileInput(''); - }); + it('should use the timeSecondsFormat specified in ngModelOptions', function() { + var inputElm = helper.compileInput( + '' + ); - it('should invalidate', function() { - helper.changeInputValueTo('1999-12-31T01:02:00'); - expect(inputElm).toBeInvalid(); - expect($rootScope.value).toBeFalsy(); - expect($rootScope.form.alias.$error.min).toBeTruthy(); + var ctrl = inputElm.controller('ngModel'); + + $rootScope.$apply(function() { + $rootScope.time = new Date(1970, 0, 1, 15, 41, 0, 500); }); + expect(inputElm.val()).toBe('1970-01-01T15:41'); - it('should validate', function() { - helper.changeInputValueTo('2000-01-01T23:02:00'); - expect(inputElm).toBeValid(); - expect(+$rootScope.value).toBe(+new Date(2000, 0, 1, 23, 2, 0)); - expect($rootScope.form.alias.$error.min).toBeFalsy(); + $rootScope.$apply(function() { + $rootScope.time = new Date(1970, 0, 1, 15, 41, 50, 500); }); + expect(inputElm.val()).toBe('1970-01-01T15:41'); - it('should revalidate when the min value changes', function() { - helper.changeInputValueTo('2000-02-01T01:02:00'); - expect(inputElm).toBeValid(); - expect($rootScope.form.alias.$error.min).toBeFalsy(); + ctrl.$overrideModelOptions({timeSecondsFormat: 'ss'}); - $rootScope.minVal = '2010-01-01T01:02:00'; - $rootScope.$digest(); + $rootScope.$apply(function() { + $rootScope.time = new Date(1970, 0, 1, 15, 41, 5, 500); + }); + expect(inputElm.val()).toBe('1970-01-01T15:41:05'); - expect(inputElm).toBeInvalid(); - expect($rootScope.form.alias.$error.min).toBeTruthy(); + ctrl.$overrideModelOptions({timeSecondsFormat: 'ss.sss'}); + + $rootScope.$apply(function() { + $rootScope.time = new Date(1970, 0, 1, 15, 41, 50, 50); }); + expect(inputElm.val()).toBe('1970-01-01T15:41:50.050'); }); - describe('max', function() { - var inputElm; - beforeEach(function() { - $rootScope.maxVal = '2019-01-01T01:02:00'; - inputElm = helper.compileInput(''); + + it('should strip empty milliseconds and seconds if specified in ngModelOptions', function() { + var inputElm = helper.compileInput( + '' + ); + + $rootScope.$apply(function() { + $rootScope.threeFortyOnePm = new Date(1970, 0, 1, 15, 41, 50, 500); + }); + + expect(inputElm.val()).toBe('1970-01-01T15:41:50.500'); + + $rootScope.$apply(function() { + $rootScope.threeFortyOnePm = new Date(1970, 0, 1, 15, 41, 0, 500); + }); + + expect(inputElm.val()).toBe('1970-01-01T15:41:00.500'); + + $rootScope.$apply(function() { + $rootScope.threeFortyOnePm = new Date(1970, 0, 1, 15, 41, 50, 0); + }); + + expect(inputElm.val()).toBe('1970-01-01T15:41:50'); + + $rootScope.$apply(function() { + $rootScope.threeFortyOnePm = new Date(1970, 0, 1, 15, 41, 0, 0); + }); + + expect(inputElm.val()).toBe('1970-01-01T15:41'); + }); + + + it('should apply timeStripZeroSeconds after timeSecondsFormat', function() { + var inputElm = helper.compileInput(''); + + $rootScope.$apply(function() { + $rootScope.threeFortyOnePm = new Date(1970, 0, 1, 15, 41, 50, 500); + }); + + expect(inputElm.val()).toBe('1970-01-01T15:41:50'); + + $rootScope.$apply(function() { + $rootScope.threeFortyOnePm = new Date(1970, 0, 1, 15, 41, 0, 500); + }); + + expect(inputElm.val()).toBe('1970-01-01T15:41'); + }); + + describe('min', function() { + var inputElm; + beforeEach(function() { + $rootScope.minVal = '2000-01-01T12:30:00'; + inputElm = helper.compileInput(''); + }); + + it('should invalidate', function() { + helper.changeInputValueTo('1999-12-31T01:02:00'); + expect(inputElm).toBeInvalid(); + expect($rootScope.value).toBeFalsy(); + expect($rootScope.form.alias.$error.min).toBeTruthy(); + }); + + it('should validate', function() { + helper.changeInputValueTo('2000-01-01T23:02:00'); + expect(inputElm).toBeValid(); + expect(+$rootScope.value).toBe(+new Date(2000, 0, 1, 23, 2, 0)); + expect($rootScope.form.alias.$error.min).toBeFalsy(); + }); + + it('should revalidate when the min value changes', function() { + helper.changeInputValueTo('2000-02-01T01:02:00'); + expect(inputElm).toBeValid(); + expect($rootScope.form.alias.$error.min).toBeFalsy(); + + $rootScope.minVal = '2010-01-01T01:02:00'; + $rootScope.$digest(); + + expect(inputElm).toBeInvalid(); + expect($rootScope.form.alias.$error.min).toBeTruthy(); + }); + + it('should validate if min is empty', function() { + $rootScope.minVal = undefined; + $rootScope.value = new Date(-9999, 0, 1, 0, 0, 0); + $rootScope.$digest(); + + expect($rootScope.form.alias.$error.min).toBeFalsy(); + }); + + it('should only validate once after compilation when inside ngRepeat', function() { + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.min).toBe(1); + + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.min).toBe(1); + }); + + }); + + describe('max', function() { + var inputElm; + beforeEach(function() { + $rootScope.maxVal = '2019-01-01T01:02:00'; + inputElm = helper.compileInput(''); }); it('should invalidate', function() { @@ -1104,6 +1621,47 @@ describe('input', function() { expect(inputElm).toBeInvalid(); expect($rootScope.form.alias.$error.max).toBeTruthy(); }); + + it('should validate if max is empty', function() { + $rootScope.maxVal = undefined; + $rootScope.value = new Date(3000, 11, 31, 23, 59, 59); + $rootScope.$digest(); + + expect($rootScope.form.alias.$error.max).toBeFalsy(); + }); + + it('should validate when timezone is provided.', function() { + inputElm = helper.compileInput(''); + $rootScope.maxVal = '2013-01-01T00:00:00'; + $rootScope.value = new Date(Date.UTC(2013, 0, 1, 0, 0, 0)); + $rootScope.$digest(); + + expect($rootScope.form.alias.$error.max).toBeFalsy(); + expect($rootScope.form.alias.$valid).toBeTruthy(); + + $rootScope.value = ''; + helper.changeInputValueTo('2013-01-01T00:00:00'); + expect(inputElm).toBeValid(); + expect($rootScope.form.alias.$error.max).toBeFalsy(); + expect($rootScope.form.alias.$valid).toBeTruthy(); + }); + + it('should only validate once after compilation when inside ngRepeat', function() { + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.max).toBe(1); + + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.max).toBe(1); + }); }); @@ -1181,6 +1739,24 @@ describe('input', function() { expect(inputElm).toBeValid(); }); + + + // Support: Edge 16 + // Edge does not support years with any number of digits other than 4. + if (!isEdge) { + it('should correctly handle 2-digit years', function() { + helper.compileInput(''); + + helper.changeInputValueTo('0001-01-01T12:34:00'); + expect($rootScope.value.getFullYear()).toBe(1); + + helper.changeInputValueTo('0099-01-01T12:34:00'); + expect($rootScope.value.getFullYear()).toBe(99); + + helper.changeInputValueTo('0100-01-01T12:34:00'); + expect($rootScope.value.getFullYear()).toBe(100); + }); + } }); @@ -1196,7 +1772,7 @@ describe('input', function() { }); - it('should set the view if the model if a valid Date object.', function() { + it('should set the view if the model is a valid Date object.', function() { var inputElm = helper.compileInput(''); $rootScope.$apply(function() { @@ -1207,7 +1783,7 @@ describe('input', function() { }); - it('should set the model undefined if the view is invalid', function() { + it('should set the model to undefined if the view is invalid', function() { var inputElm = helper.compileInput(''); $rootScope.$apply(function() { @@ -1216,12 +1792,8 @@ describe('input', function() { expect(inputElm.val()).toBe('16:25:00.000'); - try { - //set to text for browsers with time validation. - inputElm[0].setAttribute('type', 'text'); - } catch (e) { - //for IE8 - } + //set to text for browsers with time validation. + inputElm[0].setAttribute('type', 'text'); helper.changeInputValueTo('stuff'); expect(inputElm.val()).toBe('stuff'); @@ -1230,7 +1802,7 @@ describe('input', function() { }); - it('should render as blank if null', function() { + it('should set blank if null', function() { var inputElm = helper.compileInput(''); $rootScope.$apply('test = null'); @@ -1240,7 +1812,7 @@ describe('input', function() { }); - it('should come up blank when no value specified', function() { + it('should set blank when no value specified', function() { var inputElm = helper.compileInput(''); expect(inputElm.val()).toBe(''); @@ -1251,6 +1823,88 @@ describe('input', function() { expect(inputElm.val()).toBe(''); }); + it('should use the timeSecondsFormat specified in ngModelOptions', function() { + var inputElm = helper.compileInput( + '' + ); + + var ctrl = inputElm.controller('ngModel'); + + $rootScope.$apply(function() { + $rootScope.time = new Date(1970, 0, 1, 15, 41, 0, 500); + }); + expect(inputElm.val()).toBe('15:41'); + + $rootScope.$apply(function() { + $rootScope.time = new Date(1970, 0, 1, 15, 41, 50, 500); + }); + expect(inputElm.val()).toBe('15:41'); + + ctrl.$overrideModelOptions({timeSecondsFormat: 'ss'}); + + $rootScope.$apply(function() { + $rootScope.time = new Date(1970, 0, 1, 15, 41, 5, 500); + }); + expect(inputElm.val()).toBe('15:41:05'); + + ctrl.$overrideModelOptions({timeSecondsFormat: 'ss.sss'}); + + $rootScope.$apply(function() { + $rootScope.time = new Date(1970, 0, 1, 15, 41, 50, 50); + }); + expect(inputElm.val()).toBe('15:41:50.050'); + }); + + + it('should strip empty milliseconds and seconds if specified in ngModelOptions', function() { + var inputElm = helper.compileInput( + '' + ); + + $rootScope.$apply(function() { + $rootScope.threeFortyOnePm = new Date(1970, 0, 1, 15, 41, 50, 500); + }); + + expect(inputElm.val()).toBe('15:41:50.500'); + + $rootScope.$apply(function() { + $rootScope.threeFortyOnePm = new Date(1970, 0, 1, 15, 41, 0, 500); + }); + + expect(inputElm.val()).toBe('15:41:00.500'); + + $rootScope.$apply(function() { + $rootScope.threeFortyOnePm = new Date(1970, 0, 1, 15, 41, 50, 0); + }); + + expect(inputElm.val()).toBe('15:41:50'); + + $rootScope.$apply(function() { + $rootScope.threeFortyOnePm = new Date(1970, 0, 1, 15, 41, 0, 0); + }); + + expect(inputElm.val()).toBe('15:41'); + }); + + + it('should apply timeStripZeroSeconds after timeSecondsFormat', function() { + var inputElm = helper.compileInput(''); + + $rootScope.$apply(function() { + $rootScope.threeFortyOnePm = new Date(1970, 0, 1, 15, 41, 50, 500); + }); + + expect(inputElm.val()).toBe('15:41:50'); + + $rootScope.$apply(function() { + $rootScope.threeFortyOnePm = new Date(1970, 0, 1, 15, 41, 0, 500); + }); + + expect(inputElm.val()).toBe('15:41'); + }); + it('should parse empty string to null', function() { var inputElm = helper.compileInput(''); @@ -1278,6 +1932,43 @@ describe('input', function() { }); + it('should be possible to override the timezone', function() { + var inputElm = helper.compileInput(''); + + helper.changeInputValueTo('23:02:00'); + expect(+$rootScope.value).toBe(Date.UTC(1970, 0, 1, 23, 2, 0)); + + inputElm.controller('ngModel').$overrideModelOptions({timezone: '-0500'}); + $rootScope.$apply(function() { + $rootScope.value = new Date(Date.UTC(1971, 0, 1, 23, 2, 0)); + }); + expect(inputElm.val()).toBe('18:02:00.000'); + + inputElm.controller('ngModel').$overrideModelOptions({timezone: 'UTC'}); + helper.changeInputValueTo('23:02:00'); + // The year is still set from the previous date + expect(+$rootScope.value).toBe(Date.UTC(1971, 0, 1, 23, 2, 0)); + }); + + + they('should use any timezone if specified in the options (format: $prop)', + {'+HHmm': '+0500', '+HH:mm': '+05:00'}, + function(tz) { + var ngModelOptions = '{timezone: \'' + tz + '\'}'; + var inputElm = helper.compileInput( + ''); + + helper.changeInputValueTo('23:02:00'); + expect(+$rootScope.value).toBe(Date.UTC(1970, 0, 1, 18, 2, 0)); + + $rootScope.$apply(function() { + $rootScope.value = new Date(Date.UTC(1971, 0, 1, 18, 2, 0)); + }); + expect(inputElm.val()).toBe('23:02:00.000'); + } + ); + + it('should allow to specify the milliseconds', function() { var inputElm = helper.compileInput(''); @@ -1370,12 +2061,37 @@ describe('input', function() { expect(inputElm).toBeInvalid(); expect($rootScope.form.alias.$error.min).toBeTruthy(); }); + + it('should validate if min is empty', function() { + $rootScope.minVal = undefined; + $rootScope.value = new Date(-9999, 0, 1, 0, 0, 0); + $rootScope.$digest(); + + expect($rootScope.form.alias.$error.min).toBeFalsy(); + }); + + it('should only validate once after compilation when inside ngRepeat', function() { + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.min).toBe(1); + + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.min).toBe(1); + }); }); describe('max', function() { var inputElm; beforeEach(function() { - inputElm = helper.compileInput(''); + $rootScope.maxVal = '22:30:00'; + inputElm = helper.compileInput(''); }); it('should invalidate', function() { @@ -1391,11 +2107,52 @@ describe('input', function() { expect(+$rootScope.value).toBe(+new Date(1970, 0, 1, 5, 30, 0)); expect($rootScope.form.alias.$error.max).toBeFalsy(); }); + + it('should validate if max is empty', function() { + $rootScope.maxVal = undefined; + $rootScope.value = new Date(9999, 11, 31, 23, 59, 59); + $rootScope.$digest(); + + expect($rootScope.form.alias.$error.max).toBeFalsy(); + }); + + it('should validate when timezone is provided.', function() { + inputElm = helper.compileInput(''); + $rootScope.maxVal = '22:30:00'; + $rootScope.value = new Date(Date.UTC(1970, 0, 1, 22, 30, 0)); + $rootScope.$digest(); + + expect($rootScope.form.alias.$error.max).toBeFalsy(); + expect($rootScope.form.alias.$valid).toBeTruthy(); + + $rootScope.value = ''; + helper.changeInputValueTo('22:30:00'); + expect(inputElm).toBeValid(); + expect($rootScope.form.alias.$error.max).toBeFalsy(); + expect($rootScope.form.alias.$valid).toBeTruthy(); + }); + + it('should only validate once after compilation when inside ngRepeat', function() { + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.max).toBe(1); + + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.max).toBe(1); + }); }); it('should validate even if max value changes on-the-fly', function() { - $rootScope.max = '4:02:00'; + $rootScope.max = '04:02:00'; var inputElm = helper.compileInput(''); helper.changeInputValueTo('05:34:00'); @@ -1423,7 +2180,7 @@ describe('input', function() { it('should validate even if ng-max value changes on-the-fly', function() { - $rootScope.max = '4:02:00'; + $rootScope.max = '04:02:00'; var inputElm = helper.compileInput(''); helper.changeInputValueTo('05:34:00'); @@ -1497,12 +2254,8 @@ describe('input', function() { expect(inputElm.val()).toBe('2014-09-14'); - try { - //set to text for browsers with date validation. - inputElm[0].setAttribute('type', 'text'); - } catch (e) { - //for IE8 - } + //set to text for browsers with date validation. + inputElm[0].setAttribute('type', 'text'); helper.changeInputValueTo('1-2-3'); expect(inputElm.val()).toBe('1-2-3'); @@ -1559,6 +2312,56 @@ describe('input', function() { }); + it('should be possible to override the timezone', function() { + var inputElm = helper.compileInput(''); + + helper.changeInputValueTo('2000-01-01'); + expect(+$rootScope.value).toBe(Date.UTC(2000, 0, 1)); + + inputElm.controller('ngModel').$overrideModelOptions({timezone: '-0500'}); + $rootScope.$apply(function() { + $rootScope.value = new Date(Date.UTC(2001, 0, 1)); + }); + expect(inputElm.val()).toBe('2000-12-31'); + + inputElm.controller('ngModel').$overrideModelOptions({timezone: 'UTC'}); + helper.changeInputValueTo('2000-01-01'); + expect(+$rootScope.value).toBe(Date.UTC(2000, 0, 1, 0)); + }); + + + they('should use any timezone if specified in the options (format: $prop)', + {'+HHmm': '+0500', '+HH:mm': '+05:00'}, + function(tz) { + var ngModelOptions = '{timezone: \'' + tz + '\'}'; + var inputElm = helper.compileInput( + ''); + + helper.changeInputValueTo('2000-01-01'); + expect(+$rootScope.value).toBe(Date.UTC(1999, 11, 31, 19, 0, 0)); + + $rootScope.$apply(function() { + $rootScope.value = new Date(Date.UTC(2000, 11, 31, 19, 0, 0)); + }); + expect(inputElm.val()).toBe('2001-01-01'); + } + ); + + if (!isEdge) { + it('should allow four or more digits in year', function() { + var inputElm = helper.compileInput(''); + + helper.changeInputValueTo('10123-01-01'); + expect(+$rootScope.value).toBe(Date.UTC(10123, 0, 1, 0, 0, 0)); + + $rootScope.$apply(function() { + $rootScope.value = new Date(Date.UTC(20456, 1, 1, 0, 0, 0)); + }); + expect(inputElm.val()).toBe('20456-02-01'); + } + ); + } + it('should label parse errors as `date`', function() { var inputElm = helper.compileInput('', { valid: false, @@ -1612,6 +2415,34 @@ describe('input', function() { dealoc(formElm); }); + it('should not reuse the hours part of a previous date object after changing the timezone', function() { + var inputElm = helper.compileInput(''); + + helper.changeInputValueTo('2000-01-01'); + // The Date parser sets the hours part of the Date to 0 (00:00) (UTC) + expect(+$rootScope.value).toBe(Date.UTC(2000, 0, 1, 0)); + + // Change the timezone offset so that the display date is a day earlier + // This does not change the model, but our implementation + // internally caches a Date object with this offset + // and re-uses it if part of the Date changes. + // See https://github.com/angular/angular.js/commit/1a1ef62903c8fdf4ceb81277d966a8eff67f0a96 + inputElm.controller('ngModel').$overrideModelOptions({timezone: '-0500'}); + $rootScope.$apply(function() { + $rootScope.value = new Date(Date.UTC(2000, 0, 1, 0)); + }); + expect(inputElm.val()).toBe('1999-12-31'); + + // At this point, the cached Date has its hours set to to 19 (00:00 - 05:00 = 19:00) + inputElm.controller('ngModel').$overrideModelOptions({timezone: 'UTC'}); + + // When changing the timezone back to UTC, the hours part of the Date should be set to + // the default 0 (UTC) and not use the modified value of the cached Date object. + helper.changeInputValueTo('2000-01-01'); + expect(+$rootScope.value).toBe(Date.UTC(2000, 0, 1, 0)); + }); + + describe('min', function() { it('should invalidate', function() { @@ -1634,11 +2465,51 @@ describe('input', function() { var inputElm = helper.compileInput(''); $rootScope.value = new Date(2010, 1, 1, 0, 0, 0); - $rootScope.min = new Date(2014, 10, 10, 0, 0, 0); + $rootScope.min = new Date(2014, 10, 10, 0, 0, 0).toISOString(); $rootScope.$digest(); expect($rootScope.form.myControl.$error.min).toBeTruthy(); }); + + it('should parse interpolated Date objects as a valid min date value', function() { + var inputElm = helper.compileInput(''); + + $rootScope.value = new Date(2010, 1, 1, 0, 0, 0); + $rootScope.min = new Date(2014, 10, 10, 0, 0, 0); + $rootScope.$digest(); + + expect($rootScope.form.myControl.$error.min).toBeTruthy(); + }); + + it('should validate if min is empty', function() { + var inputElm = helper.compileInput( + ''); + + $rootScope.value = new Date(-9999, 0, 1, 0, 0, 0); + $rootScope.$digest(); + + expect($rootScope.form.alias.$error.min).toBeFalsy(); + }); + + it('should only validate once after compilation when inside ngRepeat', function() { + $rootScope.minVal = '2000-01-01'; + $rootScope.value = new Date(2010, 1, 1, 0, 0, 0); + + var inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.min).toBe(1); + + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.min).toBe(1); + }); + }); describe('max', function() { @@ -1662,12 +2533,69 @@ describe('input', function() { it('should parse ISO-based date strings as a valid max date value', function() { var inputElm = helper.compileInput(''); + $rootScope.value = new Date(2020, 1, 1, 0, 0, 0); + $rootScope.max = new Date(2014, 10, 10, 0, 0, 0).toISOString(); + $rootScope.$digest(); + + expect($rootScope.form.myControl.$error.max).toBeTruthy(); + }); + + it('should parse interpolated Date objects as a valid max date value', function() { + var inputElm = helper.compileInput(''); + $rootScope.value = new Date(2020, 1, 1, 0, 0, 0); $rootScope.max = new Date(2014, 10, 10, 0, 0, 0); $rootScope.$digest(); expect($rootScope.form.myControl.$error.max).toBeTruthy(); }); + + it('should validate if max is empty', function() { + var inputElm = helper.compileInput( + ''); + + $rootScope.value = new Date(9999, 11, 31, 23, 59, 59); + $rootScope.$digest(); + + expect($rootScope.form.alias.$error.max).toBeFalsy(); + }); + + it('should validate when timezone is provided.', function() { + var inputElm = helper.compileInput(''); + + $rootScope.maxVal = '2013-12-01'; + $rootScope.value = new Date(Date.UTC(2013, 11, 1, 0, 0, 0)); + $rootScope.$digest(); + + expect($rootScope.form.alias.$error.max).toBeFalsy(); + expect($rootScope.form.alias.$valid).toBeTruthy(); + + $rootScope.value = ''; + helper.changeInputValueTo('2013-12-01'); + expect(inputElm).toBeValid(); + expect($rootScope.form.alias.$error.max).toBeFalsy(); + expect($rootScope.form.alias.$valid).toBeTruthy(); + }); + + it('should only validate once after compilation when inside ngRepeat', function() { + $rootScope.maxVal = '2000-01-01'; + $rootScope.value = new Date(2020, 1, 1, 0, 0, 0); + + var inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.max).toBe(1); + + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.max).toBe(1); + }); }); @@ -1745,23 +2673,275 @@ describe('input', function() { expect(inputElm).toBeValid(); }); + + + it('should allow Date objects as valid ng-max values', function() { + $rootScope.max = new Date(2012, 1, 1, 1, 2, 0); + var inputElm = helper.compileInput(''); + + helper.changeInputValueTo('2014-01-01'); + expect(inputElm).toBeInvalid(); + + $rootScope.max = new Date(2013, 1, 1, 1, 2, 0); + $rootScope.$digest(); + + expect(inputElm).toBeInvalid(); + + $rootScope.max = new Date(2014, 1, 1, 1, 2, 0); + $rootScope.$digest(); + + expect(inputElm).toBeValid(); + }); + + + it('should allow Date objects as valid ng-min values', function() { + $rootScope.min = new Date(2013, 1, 1, 1, 2, 0); + var inputElm = helper.compileInput(''); + + helper.changeInputValueTo('2010-01-01'); + expect(inputElm).toBeInvalid(); + + $rootScope.min = new Date(2014, 1, 1, 1, 2, 0); + $rootScope.$digest(); + + expect(inputElm).toBeInvalid(); + + $rootScope.min = new Date(2009, 1, 1, 1, 2, 0); + $rootScope.$digest(); + + expect(inputElm).toBeValid(); + }); + + // Support: Edge 16 + // Edge does not support years with any number of digits other than 4. + if (!isEdge) { + it('should correctly handle 2-digit years', function() { + helper.compileInput(''); + + helper.changeInputValueTo('0001-01-01'); + expect($rootScope.value.getFullYear()).toBe(1); + + helper.changeInputValueTo('0099-01-01'); + expect($rootScope.value.getFullYear()).toBe(99); + + helper.changeInputValueTo('0100-01-01'); + expect($rootScope.value.getFullYear()).toBe(100); + }); + } + + + describe('ISO_DATE_REGEXP', function() { + var dates = [ + // Validate date + ['00:00:00.0000+01:01', false], // date must be specified + ['2010.06.15T00:00:00.0000+01:01', false], // date must use dash separator + ['x2010-06-15T00:00:00.0000+01:01', false], // invalid leading characters + + // Validate year + ['2010-06-15T00:00:00.0000+01:01', true], // year has four or more digits + ['20100-06-15T00:00:00.0000+01:01', true], // year has four or more digits + ['-06-15T00:00:00.0000+01:01', false], // year has too few digits + ['2-06-15T00:00:00.0000+01:01', false], // year has too few digits + ['20-06-15T00:00:00.0000+01:01', false], // year has too few digits + ['201-06-15T00:00:00.0000+01:01', false], // year has too few digits + + // Validate month + ['2010-01-15T00:00:00.0000+01:01', true], // month has two digits + ['2010--15T00:00:00.0000+01:01', false], // month has too few digits + ['2010-0-15T00:00:00.0000+01:01', false], // month has too few digits + ['2010-1-15T00:00:00.0000+01:01', false], // month has too few digits + ['2010-111-15T00:00:00.0000+01:01', false], // month has too many digits + ['2010-22-15T00:00:00.0000+01:01', false], // month is too large + + // Validate day + ['2010-01-01T00:00:00.0000+01:01', true], // day has two digits + ['2010-01-T00:00:00.0000+01:01', false], // day has too few digits + ['2010-01-1T00:00:00.0000+01:01', false], // day has too few digits + ['2010-01-200T00:00:00.0000+01:01', false], // day has too many digits + ['2010-01-41T00:00:00.0000+01:01', false], // day is too large + + // Validate time + ['2010-01-01', false], // time must be specified + ['2010-01-0101:00:00.0000+01:01', false], // missing date time separator + ['2010-01-01V01:00:00.0000+01:01', false], // invalid date time separator + ['2010-01-01T01-00-00.0000+01:01', false], // time must use colon separator + + // Validate hour + ['2010-01-01T01:00:00.0000+01:01', true], // hour has two digits + ['2010-01-01T-01:00:00.0000+01:01', false], // hour must be positive + ['2010-01-01T:00:00.0000+01:01', false], // hour has too few digits + ['2010-01-01T1:00:00.0000+01:01', false], // hour has too few digits + ['2010-01-01T220:00:00.0000+01:01', false], // hour has too many digits + ['2010-01-01T32:00:00.0000+01:01', false], // hour is too large + + // Validate minutes + ['2010-01-01T01:00:00.0000+01:01', true], // minute has two digits + ['2010-01-01T01:-00:00.0000+01:01', false], // minute must be positive + ['2010-01-01T01::00.0000+01:01', false], // minute has too few digits + ['2010-01-01T01:0:00.0000+01:01', false], // minute has too few digits + ['2010-01-01T01:100:00.0000+01:01', false], // minute has too many digits + ['2010-01-01T01:60:00.0000+01:01', false], // minute is too large + + // Validate seconds + ['2010-01-01T01:00:00.0000+01:01', true], // second has two digits + ['2010-01-01T01:00:-00.0000+01:01', false], // second must be positive + ['2010-01-01T01:00:.0000+01:01', false], // second has too few digits + ['2010-01-01T01:00:0.0000+01:01', false], // second has too few digits + ['2010-01-01T01:00:100.0000+01:01', false], // second has too many digits + ['2010-01-01T01:00:60.0000+01:01', false], // second is too large + + // Validate milliseconds + ['2010-01-01T01:00:00+01:01', false], // millisecond must be specified + ['2010-01-01T01:00:00.-0000+01:01', false], // millisecond must be positive + ['2010-01-01T01:00:00:0000+01:01', false], // millisecond must use period separator + ['2010-01-01T01:00:00.+01:01', false], // millisecond has too few digits + + // Validate timezone + ['2010-06-15T00:00:00.0000', false], // timezone must be specified + + // Validate timezone offset + ['2010-06-15T00:00:00.0000+01:01', true], // timezone offset can be positive hours and minutes + ['2010-06-15T00:00:00.0000-01:01', true], // timezone offset can be negative hours and minutes + ['2010-06-15T00:00:00.0000~01:01', false], // timezone has postive/negative indicator + ['2010-06-15T00:00:00.000001:01', false], // timezone has postive/negative indicator + ['2010-06-15T00:00:00.0000+00:01Z', false], // timezone invalid trailing characters + ['2010-06-15T00:00:00.0000+00:01 ', false], // timezone invalid trailing characters + + // Validate timezone hour offset + ['2010-06-15T00:00:00.0000+:01', false], // timezone hour offset has too few digits + ['2010-06-15T00:00:00.0000+0:01', false], // timezone hour offset has too few digits + ['2010-06-15T00:00:00.0000+211:01', false], // timezone hour offset too many digits + ['2010-06-15T00:00:00.0000+31:01', false], // timezone hour offset value too large + + // Validate timezone minute offset + ['2010-06-15T00:00:00.0000+00:-01', false], // timezone minute offset must be positive + ['2010-06-15T00:00:00.0000+00.01', false], // timezone minute offset must use colon separator + ['2010-06-15T00:00:00.0000+0101', false], // timezone minute offset must use colon separator + ['2010-06-15T00:00:00.0000+010', false], // timezone minute offset must use colon separator + ['2010-06-15T00:00:00.0000+00', false], // timezone minute offset has too few digits + ['2010-06-15T00:00:00.0000+00:', false], // timezone minute offset has too few digits + ['2010-06-15T00:00:00.0000+00:0', false], // timezone minute offset has too few digits + ['2010-06-15T00:00:00.0000+00:211', false], // timezone minute offset has too many digits + ['2010-06-15T00:00:00.0000+01010', false], // timezone minute offset has too many digits + ['2010-06-15T00:00:00.0000+00:61', false], // timezone minute offset is too large + + // Validate timezone UTC + ['2010-06-15T00:00:00.0000Z', true], // UTC timezone can be indicated with Z + ['2010-06-15T00:00:00.0000K', false], // UTC timezone indicator is invalid + ['2010-06-15T00:00:00.0000 Z', false], // UTC timezone indicator has extra space + ['2010-06-15T00:00:00.0000ZZ', false], // UTC timezone indicator invalid trailing characters + ['2010-06-15T00:00:00.0000Z ', false] // UTC timezone indicator invalid trailing characters + ]; + + they('should validate date: $prop', dates, function(item) { + var date = item[0]; + var valid = item[1]; + + /* global ISO_DATE_REGEXP: false */ + expect(ISO_DATE_REGEXP.test(date)).toBe(valid); + }); + }); + }); + + ['month', 'week', 'time', 'date', 'datetime-local'].forEach(function(inputType) { + if (jqLite('').prop('type') !== inputType) { + return; + } + + describe(inputType, function() { + they('should re-validate and dirty when partially editing the input value ($prop event)', + ['keydown', 'wheel', 'mousedown'], + function(validationEvent) { + var mockValidity = {valid: true, badInput: false}; + var inputElm = helper.compileInput('', mockValidity); + + expect(inputElm).toBeValid(); + expect($rootScope.form.alias.$pristine).toBeTruthy(); + + inputElm.triggerHandler({type: validationEvent}); + mockValidity.valid = false; + mockValidity.badInput = true; + $browser.defer.flush(); + expect(inputElm).toBeInvalid(); + expect($rootScope.form.alias.$pristine).toBeFalsy(); + } + ); + + they('should do nothing when $prop event fired but validity does not change', + ['keydown', 'wheel', 'mousedown'], + function(validationEvent) { + var mockValidity = {valid: true, badInput: false}; + var inputElm = helper.compileInput('', mockValidity); + + expect(inputElm).toBeValid(); + expect($rootScope.form.alias.$pristine).toBeTruthy(); + + inputElm.triggerHandler({type: validationEvent}); + $browser.defer.flush(); + expect(inputElm).toBeValid(); + expect($rootScope.form.alias.$pristine).toBeTruthy(); + } + ); + + they('should re-validate dirty when already $invalid and partially editing the input value ($prop event)', + ['keydown', 'wheel', 'mousedown'], + function(validationEvent) { + var mockValidity = {valid: false, valueMissing: true, badInput: false}; + var inputElm = helper.compileInput('', mockValidity); + + expect(inputElm).toBeInvalid(); + expect($rootScope.form.alias.$pristine).toBeTruthy(); + + inputElm.triggerHandler({type: validationEvent}); + mockValidity.valid = false; + mockValidity.valueMissing = true; + mockValidity.badInput = true; + $browser.defer.flush(); + expect(inputElm).toBeInvalid(); + expect($rootScope.form.alias.$pristine).toBeFalsy(); + } + ); + + they('should do nothing when already $invalid and $prop event fired but validity does not change', + ['keydown', 'wheel', 'mousedown'], + function(validationEvent) { + var mockValidity = {valid: false, valueMissing: true, badInput: false}; + var inputElm = helper.compileInput('', mockValidity); + + expect(inputElm).toBeInvalid(); + expect($rootScope.form.alias.$pristine).toBeTruthy(); + + inputElm.triggerHandler({type: validationEvent}); + $browser.defer.flush(); + expect(inputElm).toBeInvalid(); + expect($rootScope.form.alias.$pristine).toBeTruthy(); + } + ); + }); }); describe('number', function() { + // Helpers for min / max tests + var subtract = function(value) { + return value - 5; + }; + + var add = function(value) { + return value + 5; + }; + it('should reset the model if view is invalid', function() { var inputElm = helper.compileInput(''); $rootScope.$apply('age = 123'); expect(inputElm.val()).toBe('123'); - try { - // to allow non-number values, we have to change type so that - // the browser which have number validation will not interfere with - // this test. IE8 won't allow it hence the catch. - inputElm[0].setAttribute('type', 'text'); - } catch (e) {} + // to allow non-number values, we have to change type so that + // the browser which have number validation will not interfere with + // this test. + inputElm[0].setAttribute('type', 'text'); helper.changeInputValueTo('123X'); expect(inputElm.val()).toBe('123X'); @@ -1848,7 +3028,139 @@ describe('input', function() { expect(function() { $rootScope.value = 'one'; var inputElm = helper.compileInput(''); - }).toThrowMinErr('ngModel', 'numfmt', "Expected `one` to be a number"); + }).toThrowMinErr('ngModel', 'numfmt', 'Expected `one` to be a number'); + }); + + + it('should parse exponential notation', function() { + var inputElm = helper.compileInput(''); + + // #.###e+## + $rootScope.form.alias.$setViewValue('1.23214124123412412e+26'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(1.23214124123412412e+26); + + // #.###e## + $rootScope.form.alias.$setViewValue('1.23214124123412412e26'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(1.23214124123412412e26); + + // #.###e-## + $rootScope.form.alias.$setViewValue('1.23214124123412412e-26'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(1.23214124123412412e-26); + + // ####e+## + $rootScope.form.alias.$setViewValue('123214124123412412e+26'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(123214124123412412e26); + + // ####e## + $rootScope.form.alias.$setViewValue('123214124123412412e26'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(123214124123412412e26); + + // ####e-## + $rootScope.form.alias.$setViewValue('123214124123412412e-26'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(123214124123412412e-26); + + // #.###E+## + $rootScope.form.alias.$setViewValue('1.23214124123412412E+26'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(1.23214124123412412e+26); + + // #.###E## + $rootScope.form.alias.$setViewValue('1.23214124123412412E26'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(1.23214124123412412e26); + + // #.###E-## + $rootScope.form.alias.$setViewValue('1.23214124123412412E-26'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(1.23214124123412412e-26); + + // ####E+## + $rootScope.form.alias.$setViewValue('123214124123412412E+26'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(123214124123412412e26); + + // ####E## + $rootScope.form.alias.$setViewValue('123214124123412412E26'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(123214124123412412e26); + + // ####E-## + $rootScope.form.alias.$setViewValue('123214124123412412E-26'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(123214124123412412e-26); + }); + + it('should not set $error number if any other parser fails', function() { + var inputElm = helper.compileInput(''); + var ctrl = inputElm.controller('ngModel'); + + var previousParserFail = false; + var laterParserFail = false; + + ctrl.$parsers.unshift(function(value) { + return previousParserFail ? undefined : value; + }); + + ctrl.$parsers.push(function(value) { + return laterParserFail ? undefined : value; + }); + + // to allow non-number values, we have to change type so that + // the browser which have number validation will not interfere with + // this test. + inputElm[0].setAttribute('type', 'text'); + + helper.changeInputValueTo('123X'); + expect(inputElm.val()).toBe('123X'); + + expect($rootScope.age).toBeUndefined(); + expect(inputElm).toBeInvalid(); + expect(ctrl.$error.number).toBe(true); + expect(ctrl.$error.parse).toBeFalsy(); + expect(inputElm).toHaveClass('ng-invalid-number'); + expect(inputElm).not.toHaveClass('ng-invalid-parse'); + + previousParserFail = true; + helper.changeInputValueTo('123'); + expect(inputElm.val()).toBe('123'); + + expect($rootScope.age).toBeUndefined(); + expect(inputElm).toBeInvalid(); + expect(ctrl.$error.number).toBeFalsy(); + expect(ctrl.$error.parse).toBe(true); + expect(inputElm).not.toHaveClass('ng-invalid-number'); + expect(inputElm).toHaveClass('ng-invalid-parse'); + + previousParserFail = false; + laterParserFail = true; + + helper.changeInputValueTo('1234'); + expect(inputElm.val()).toBe('1234'); + + expect($rootScope.age).toBeUndefined(); + expect(inputElm).toBeInvalid(); + expect(ctrl.$error.number).toBeFalsy(); + expect(ctrl.$error.parse).toBe(true); + expect(inputElm).not.toHaveClass('ng-invalid-number'); + expect(inputElm).toHaveClass('ng-invalid-parse'); + + laterParserFail = false; + + helper.changeInputValueTo('12345'); + expect(inputElm.val()).toBe('12345'); + + expect($rootScope.age).toBe(12345); + expect(inputElm).toBeValid(); + expect(ctrl.$error.number).toBeFalsy(); + expect(ctrl.$error.parse).toBeFalsy(); + expect(inputElm).not.toHaveClass('ng-invalid-number'); + expect(inputElm).not.toHaveClass('ng-invalid-parse'); }); @@ -1868,6 +3180,29 @@ describe('input', function() { expect($rootScope.form.alias.$error.min).toBeFalsy(); }); + + it('should validate against the viewValue', function() { + var inputElm = helper.compileInput( + ''); + + var ngModelCtrl = inputElm.controller('ngModel'); + ngModelCtrl.$parsers.push(subtract); + + helper.changeInputValueTo('10'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(5); + expect($rootScope.form.alias.$error.min).toBeFalsy(); + + ngModelCtrl.$parsers.pop(); + ngModelCtrl.$parsers.push(add); + + helper.changeInputValueTo('5'); + expect(inputElm).toBeInvalid(); + expect($rootScope.form.alias.$error.min).toBeTruthy(); + expect($rootScope.value).toBe(10); + }); + + it('should validate even if min value changes on-the-fly', function() { $rootScope.min = undefined; var inputElm = helper.compileInput(''); @@ -1896,6 +3231,18 @@ describe('input', function() { $rootScope.$digest(); expect(inputElm).toBeValid(); }); + + it('should only validate once after compilation when inside ngRepeat', function() { + $rootScope.value = 5; + $rootScope.minVal = 3; + var inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.min).toBe(1); + }); + }); describe('ngMin', function() { @@ -1914,6 +3261,28 @@ describe('input', function() { expect($rootScope.form.alias.$error.min).toBeFalsy(); }); + + it('should validate against the viewValue', function() { + var inputElm = helper.compileInput( + ''); + var ngModelCtrl = inputElm.controller('ngModel'); + ngModelCtrl.$parsers.push(subtract); + + helper.changeInputValueTo('10'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(5); + expect($rootScope.form.alias.$error.min).toBeFalsy(); + + ngModelCtrl.$parsers.pop(); + ngModelCtrl.$parsers.push(add); + + helper.changeInputValueTo('5'); + expect(inputElm).toBeInvalid(); + expect($rootScope.form.alias.$error.min).toBeTruthy(); + expect($rootScope.value).toBe(10); + }); + + it('should validate even if the ngMin value changes on-the-fly', function() { $rootScope.min = undefined; var inputElm = helper.compileInput(''); @@ -1942,6 +3311,17 @@ describe('input', function() { $rootScope.$digest(); expect(inputElm).toBeValid(); }); + + it('should only validate once after compilation when inside ngRepeat', function() { + $rootScope.value = 5; + $rootScope.minVal = 3; + var inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.min).toBe(1); + }); }); @@ -1961,6 +3341,28 @@ describe('input', function() { expect($rootScope.form.alias.$error.max).toBeFalsy(); }); + + it('should validate against the viewValue', function() { + var inputElm = helper.compileInput(''); + var ngModelCtrl = inputElm.controller('ngModel'); + ngModelCtrl.$parsers.push(add); + + helper.changeInputValueTo('10'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(15); + expect($rootScope.form.alias.$error.max).toBeFalsy(); + + ngModelCtrl.$parsers.pop(); + ngModelCtrl.$parsers.push(subtract); + + helper.changeInputValueTo('15'); + expect(inputElm).toBeInvalid(); + expect($rootScope.form.alias.$error.max).toBeTruthy(); + expect($rootScope.value).toBe(10); + }); + + it('should validate even if max value changes on-the-fly', function() { $rootScope.max = undefined; var inputElm = helper.compileInput(''); @@ -1989,6 +3391,18 @@ describe('input', function() { $rootScope.$digest(); expect(inputElm).toBeValid(); }); + + it('should only validate once after compilation when inside ngRepeat', function() { + $rootScope.value = 5; + $rootScope.maxVal = 3; + var inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.max).toBe(1); + }); + }); describe('ngMax', function() { @@ -2007,6 +3421,28 @@ describe('input', function() { expect($rootScope.form.alias.$error.max).toBeFalsy(); }); + + it('should validate against the viewValue', function() { + var inputElm = helper.compileInput(''); + var ngModelCtrl = inputElm.controller('ngModel'); + ngModelCtrl.$parsers.push(add); + + helper.changeInputValueTo('10'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(15); + expect($rootScope.form.alias.$error.max).toBeFalsy(); + + ngModelCtrl.$parsers.pop(); + ngModelCtrl.$parsers.push(subtract); + + helper.changeInputValueTo('15'); + expect(inputElm).toBeInvalid(); + expect($rootScope.form.alias.$error.max).toBeTruthy(); + expect($rootScope.value).toBe(10); + }); + + it('should validate even if the ngMax value changes on-the-fly', function() { $rootScope.max = undefined; var inputElm = helper.compileInput(''); @@ -2035,19 +3471,218 @@ describe('input', function() { $rootScope.$digest(); expect(inputElm).toBeValid(); }); + + it('should only validate once after compilation when inside ngRepeat', function() { + $rootScope.value = 5; + $rootScope.maxVal = 3; + var inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.max).toBe(1); + }); }); - describe('required', function() { + forEach({ + step: 'step="{{step}}"', + ngStep: 'ng-step="step"' + }, function(attrHtml, attrName) { - it('should be valid even if value is 0', function() { - var inputElm = helper.compileInput(''); + describe(attrName, function() { - helper.changeInputValueTo('0'); - expect(inputElm).toBeValid(); - expect($rootScope.value).toBe(0); - expect($rootScope.form.alias.$error.required).toBeFalsy(); - }); + it('should validate', function() { + $rootScope.step = 10; + $rootScope.value = 20; + var inputElm = helper.compileInput( + ''); + + expect(inputElm.val()).toBe('20'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(20); + expect($rootScope.form.alias.$error.step).toBeFalsy(); + + helper.changeInputValueTo('18'); + expect(inputElm).toBeInvalid(); + expect(inputElm.val()).toBe('18'); + expect($rootScope.value).toBeUndefined(); + expect($rootScope.form.alias.$error.step).toBeTruthy(); + + helper.changeInputValueTo('10'); + expect(inputElm).toBeValid(); + expect(inputElm.val()).toBe('10'); + expect($rootScope.value).toBe(10); + expect($rootScope.form.alias.$error.step).toBeFalsy(); + + $rootScope.$apply('value = 12'); + expect(inputElm).toBeInvalid(); + expect(inputElm.val()).toBe('12'); + expect($rootScope.value).toBe(12); + expect($rootScope.form.alias.$error.step).toBeTruthy(); + }); + + it('should validate even if the step value changes on-the-fly', function() { + $rootScope.step = 10; + var inputElm = helper.compileInput( + ''); + + helper.changeInputValueTo('10'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(10); + + // Step changes, but value matches + $rootScope.$apply('step = 5'); + expect(inputElm.val()).toBe('10'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(10); + expect($rootScope.form.alias.$error.step).toBeFalsy(); + + // Step changes, value does not match + $rootScope.$apply('step = 6'); + expect(inputElm).toBeInvalid(); + expect($rootScope.value).toBeUndefined(); + expect(inputElm.val()).toBe('10'); + expect($rootScope.form.alias.$error.step).toBeTruthy(); + + // null = valid + $rootScope.$apply('step = null'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(10); + expect(inputElm.val()).toBe('10'); + expect($rootScope.form.alias.$error.step).toBeFalsy(); + + // Step val as string + $rootScope.$apply('step = "7"'); + expect(inputElm).toBeInvalid(); + expect($rootScope.value).toBeUndefined(); + expect(inputElm.val()).toBe('10'); + expect($rootScope.form.alias.$error.step).toBeTruthy(); + + // unparsable string is ignored + $rootScope.$apply('step = "abc"'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(10); + expect(inputElm.val()).toBe('10'); + expect($rootScope.form.alias.$error.step).toBeFalsy(); + }); + + it('should use the correct "step base" when `[min]` is specified', function() { + $rootScope.min = 5; + $rootScope.step = 10; + $rootScope.value = 10; + var inputElm = helper.compileInput( + ''); + var ngModel = inputElm.controller('ngModel'); + + expect(inputElm.val()).toBe('10'); + expect(inputElm).toBeInvalid(); + expect(ngModel.$error.step).toBe(true); + expect($rootScope.value).toBe(10); // an initially invalid value should not be changed + + helper.changeInputValueTo('15'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(15); + + $rootScope.$apply('step = 3'); + expect(inputElm.val()).toBe('15'); + expect(inputElm).toBeInvalid(); + expect(ngModel.$error.step).toBe(true); + expect($rootScope.value).toBeUndefined(); + + helper.changeInputValueTo('8'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(8); + + $rootScope.$apply('min = 10; step = 20'); + helper.changeInputValueTo('30'); + expect(inputElm.val()).toBe('30'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(30); + + $rootScope.$apply('min = 5'); + expect(inputElm.val()).toBe('30'); + expect(inputElm).toBeInvalid(); + expect(ngModel.$error.step).toBe(true); + expect($rootScope.value).toBeUndefined(); + + $rootScope.$apply('step = 0.00000001'); + expect(inputElm.val()).toBe('30'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(30); + + // 0.3 - 0.2 === 0.09999999999999998 + $rootScope.$apply('min = 0.2; step = (0.3 - 0.2)'); + helper.changeInputValueTo('0.3'); + expect(inputElm.val()).toBe('0.3'); + expect(inputElm).toBeInvalid(); + expect(ngModel.$error.step).toBe(true); + expect($rootScope.value).toBeUndefined(); + }); + + it('should correctly validate even in cases where the JS floating point arithmetic fails', + function() { + $rootScope.step = 0.1; + var inputElm = helper.compileInput( + ''); + var ngModel = inputElm.controller('ngModel'); + + expect(inputElm.val()).toBe(''); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBeUndefined(); + + helper.changeInputValueTo('0.3'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(0.3); + + helper.changeInputValueTo('2.9999999999999996'); + expect(inputElm).toBeInvalid(); + expect(ngModel.$error.step).toBe(true); + expect($rootScope.value).toBeUndefined(); + + // 0.5 % 0.1 === 0.09999999999999998 + helper.changeInputValueTo('0.5'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(0.5); + + // 3.5 % 0.1 === 0.09999999999999981 + helper.changeInputValueTo('3.5'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(3.5); + + // 1.16 % 0.01 === 0.009999999999999896 + // 1.16 * 100 === 115.99999999999999 + $rootScope.step = 0.01; + helper.changeInputValueTo('1.16'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(1.16); + } + ); + + it('should validate only once after compilation inside ngRepeat', function() { + $rootScope.step = 10; + $rootScope.value = 20; + var inputElm = helper.compileInput('
' + + '' + + '
'); + + expect(helper.validationCounter.step).toBe(1); + }); + + }); + }); + + + describe('required', function() { + + it('should be valid even if value is 0', function() { + var inputElm = helper.compileInput(''); + + helper.changeInputValueTo('0'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(0); + expect($rootScope.form.alias.$error.required).toBeFalsy(); + }); it('should be valid even if value 0 is set from model', function() { var inputElm = helper.compileInput(''); @@ -2062,7 +3697,7 @@ describe('input', function() { it('should register required on non boolean elements', function() { var inputElm = helper.compileInput('
'); - $rootScope.$apply("value = ''"); + $rootScope.$apply('value = \'\''); expect(inputElm).toBeInvalid(); expect($rootScope.form.alias.$error.required).toBeTruthy(); @@ -2071,10 +3706,20 @@ describe('input', function() { it('should not invalidate number if ng-required=false and viewValue has not been committed', function() { var inputElm = helper.compileInput(''); - $rootScope.$apply("required = false"); + $rootScope.$apply('required = false'); expect(inputElm).toBeValid(); }); + + it('should only validate once after compilation when inside ngRepeat', function() { + $rootScope.value = 'text'; + var inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.required).toBe(1); + }); }); describe('ngRequired', function() { @@ -2103,7 +3748,7 @@ describe('input', function() { it('should register required on non boolean elements', function() { var inputElm = helper.compileInput('
'); - $rootScope.$apply("value = ''"); + $rootScope.$apply('value = \'\''); expect(inputElm).toBeInvalid(); expect($rootScope.form.numberInput.$error.required).toBeTruthy(); @@ -2124,6 +3769,17 @@ describe('input', function() { expect($rootScope.value).toBeUndefined(); expect($rootScope.form.numberInput.$error.required).toBeFalsy(); }); + + it('should only validate once after compilation when inside ngRepeat', function() { + $rootScope.value = 'text'; + $rootScope.isRequired = true; + var inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.required).toBe(1); + }); }); describe('when the ngRequired expression initially evaluates to false', function() { @@ -2149,7 +3805,7 @@ describe('input', function() { it('should not register required on non boolean elements', function() { var inputElm = helper.compileInput('
'); - $rootScope.$apply("value = ''"); + $rootScope.$apply('value = \'\''); expect(inputElm).toBeValid(); expect($rootScope.form.numberInput.$error.required).toBeFalsy(); @@ -2265,6 +3921,736 @@ describe('input', function() { }); }); + describe('range', function() { + var scope; + + var rangeTestEl = angular.element(''); + var supportsRange = rangeTestEl[0].type === 'range'; + beforeEach(function() { + scope = $rootScope; + }); + + if (supportsRange) { + // This behavior only applies to browsers that implement the range input, which do not + // allow to set a non-number value and will set the value of the input to 50 even when you + // change it directly on the element. + // Other browsers fall back to text inputs, where setting a model value of 50 does not make + // sense if the input value is a string. These browsers will mark the input as invalid instead. + + it('should render as 50 if null', function() { + var inputElm = helper.compileInput(''); + + helper.changeInputValueTo('25'); + expect(scope.age).toBe(25); + + scope.$apply('age = null'); + + expect(inputElm.val()).toEqual('50'); + }); + + it('should set model to 50 when no value specified and default min/max', function() { + var inputElm = helper.compileInput(''); + + expect(inputElm.val()).toBe('50'); + + scope.$apply('age = null'); + + expect(scope.age).toBe(50); + }); + + it('should parse non-number values to 50 when default min/max', function() { + var inputElm = helper.compileInput(''); + + scope.$apply('age = 10'); + expect(inputElm.val()).toBe('10'); + + helper.changeInputValueTo(''); + expect(scope.age).toBe(50); + expect(inputElm).toBeValid(); + }); + } else { + + it('should reset the model if view is invalid', function() { + var inputElm = helper.compileInput(''); + + scope.$apply('age = 100'); + expect(inputElm.val()).toBe('100'); + + helper.changeInputValueTo('100X'); + expect(inputElm.val()).toBe('100X'); + expect(scope.age).toBeUndefined(); + expect(inputElm).toBeInvalid(); + }); + } + + it('should parse the input value to a Number', function() { + var inputElm = helper.compileInput(''); + + helper.changeInputValueTo('75'); + expect(scope.age).toBe(75); + }); + + + it('should only invalidate the model if suffering from bad input when the data is parsed', function() { + scope.age = 60; + + var inputElm = helper.compileInput('', { + valid: false, + badInput: true + }); + + expect(inputElm).toBeValid(); + + helper.changeInputValueTo('this-will-fail-because-of-the-badInput-flag'); + + expect(scope.age).toBeUndefined(); + expect(inputElm).toBeInvalid(); + }); + + + it('should throw if the model value is not a number', function() { + expect(function() { + scope.value = 'one'; + var inputElm = helper.compileInput(''); + }).toThrowMinErr('ngModel', 'numfmt', 'Expected `one` to be a number'); + }); + + + describe('min', function() { + + if (supportsRange) { + + it('should initialize correctly with non-default model and min value', function() { + scope.value = -3; + scope.min = -5; + var inputElm = helper.compileInput(''); + + expect(inputElm).toBeValid(); + expect(inputElm.val()).toBe('-3'); + expect(scope.value).toBe(-3); + expect(scope.form.alias.$error.min).toBeFalsy(); + }); + + // Browsers that implement range will never allow you to set the value < min values + it('should adjust invalid input values', function() { + var inputElm = helper.compileInput(''); + + helper.changeInputValueTo('5'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(10); + expect(scope.form.alias.$error.min).toBeFalsy(); + + helper.changeInputValueTo('100'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(100); + expect(scope.form.alias.$error.min).toBeFalsy(); + }); + + it('should set the model to the min val if it is less than the min val', function() { + scope.value = -10; + // Default min is 0 + var inputElm = helper.compileInput(''); + + expect(inputElm).toBeValid(); + expect(inputElm.val()).toBe('0'); + expect(scope.value).toBe(0); + + scope.$apply('value = 5; min = 10'); + + expect(inputElm).toBeValid(); + expect(inputElm.val()).toBe('10'); + expect(scope.value).toBe(10); + }); + + it('should adjust the element and model value when the min value changes on-the-fly', function() { + scope.min = 10; + var inputElm = helper.compileInput(''); + + helper.changeInputValueTo('15'); + expect(inputElm).toBeValid(); + + scope.min = 20; + scope.$digest(); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(20); + expect(inputElm.val()).toBe('20'); + + scope.min = null; + scope.$digest(); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(20); + expect(inputElm.val()).toBe('20'); + + scope.min = '15'; + scope.$digest(); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(20); + expect(inputElm.val()).toBe('20'); + + scope.min = 'abc'; + scope.$digest(); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(20); + expect(inputElm.val()).toBe('20'); + }); + + it('should only validate once after compilation when inside ngRepeat', function() { + $rootScope.minVal = 5; + $rootScope.value = 10; + helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.min).toBe(1); + }); + + } else { + // input[type=range] will become type=text in browsers that don't support it + + it('should validate if "range" is not implemented', function() { + var inputElm = helper.compileInput(''); + + helper.changeInputValueTo('5'); + expect(inputElm).toBeInvalid(); + expect(scope.value).toBeUndefined(); + expect(scope.form.alias.$error.min).toBeTruthy(); + + helper.changeInputValueTo('100'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(100); + expect(scope.form.alias.$error.min).toBeFalsy(); + }); + + it('should not assume a min val of 0 if the min interpolates to a non-number', function() { + scope.value = -10; + var inputElm = helper.compileInput(''); + + expect(inputElm).toBeValid(); + expect(inputElm.val()).toBe('-10'); + expect(scope.value).toBe(-10); + expect(scope.form.alias.$error.min).toBeFalsy(); + + helper.changeInputValueTo('-5'); + expect(inputElm).toBeValid(); + expect(inputElm.val()).toBe('-5'); + expect(scope.value).toBe(-5); + expect(scope.form.alias.$error.min).toBeFalsy(); + + scope.$apply('max = "null"'); + expect(inputElm).toBeValid(); + expect(inputElm.val()).toBe('-5'); + expect(scope.value).toBe(-5); + expect(scope.form.alias.$error.max).toBeFalsy(); + + scope.$apply('max = "asdf"'); + expect(inputElm).toBeValid(); + expect(inputElm.val()).toBe('-5'); + expect(scope.value).toBe(-5); + expect(scope.form.alias.$error.max).toBeFalsy(); + }); + + it('should validate even if the min value changes on-the-fly', function() { + scope.min = 10; + var inputElm = helper.compileInput(''); + + helper.changeInputValueTo('15'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(15); + + scope.min = 20; + scope.$digest(); + expect(inputElm).toBeInvalid(); + expect(scope.value).toBeUndefined(); + expect(inputElm.val()).toBe('15'); + + scope.min = null; + scope.$digest(); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(15); + expect(inputElm.val()).toBe('15'); + + scope.min = '16'; + scope.$digest(); + expect(inputElm).toBeInvalid(); + expect(scope.value).toBeUndefined(); + expect(inputElm.val()).toBe('15'); + + scope.min = 'abc'; + scope.$digest(); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(15); + expect(inputElm.val()).toBe('15'); + }); + + it('should only validate once after compilation when inside ngRepeat', function() { + $rootScope.minVal = 5; + $rootScope.value = 10; + helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.min).toBe(1); + }); + } + }); + + describe('max', function() { + + if (supportsRange) { + // Browsers that implement range will never allow you to set the value > max value + it('should initialize correctly with non-default model and max value', function() { + scope.value = 130; + scope.max = 150; + var inputElm = helper.compileInput(''); + + expect(inputElm).toBeValid(); + expect(inputElm.val()).toBe('130'); + expect(scope.value).toBe(130); + expect(scope.form.alias.$error.max).toBeFalsy(); + }); + + it('should validate', function() { + var inputElm = helper.compileInput(''); + + helper.changeInputValueTo('20'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(10); + expect(scope.form.alias.$error.max).toBeFalsy(); + + helper.changeInputValueTo('0'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(0); + expect(scope.form.alias.$error.max).toBeFalsy(); + }); + + it('should set the model to the max val if it is greater than the max val', function() { + scope.value = 110; + // Default max is 100 + var inputElm = helper.compileInput(''); + + expect(inputElm).toBeValid(); + expect(inputElm.val()).toBe('100'); + expect(scope.value).toBe(100); + + scope.$apply('value = 90; max = 10'); + + expect(inputElm).toBeValid(); + expect(inputElm.val()).toBe('10'); + expect(scope.value).toBe(10); + }); + + it('should adjust the element and model value if the max value changes on-the-fly', function() { + scope.max = 10; + var inputElm = helper.compileInput(''); + + helper.changeInputValueTo('5'); + expect(inputElm).toBeValid(); + + scope.max = 0; + scope.$digest(); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(0); + expect(inputElm.val()).toBe('0'); + + scope.max = null; + scope.$digest(); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(0); + expect(inputElm.val()).toBe('0'); + + scope.max = '4'; + scope.$digest(); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(0); + expect(inputElm.val()).toBe('0'); + + scope.max = 'abc'; + scope.$digest(); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(0); + expect(inputElm.val()).toBe('0'); + }); + + it('should only validate once after compilation when inside ngRepeat and the value is valid', function() { + $rootScope.maxVal = 5; + $rootScope.value = 5; + helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.max).toBe(1); + }); + + } else { + it('should validate if "range" is not implemented', function() { + var inputElm = helper.compileInput(''); + + helper.changeInputValueTo('20'); + expect(inputElm).toBeInvalid(); + expect(scope.value).toBeUndefined(); + expect(scope.form.alias.$error.max).toBeTruthy(); + + helper.changeInputValueTo('0'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(0); + expect(scope.form.alias.$error.max).toBeFalsy(); + }); + + it('should not assume a max val of 100 if the max attribute interpolates to a non-number', function() { + scope.value = 120; + var inputElm = helper.compileInput(''); + + expect(inputElm).toBeValid(); + expect(inputElm.val()).toBe('120'); + expect(scope.value).toBe(120); + expect(scope.form.alias.$error.max).toBeFalsy(); + + helper.changeInputValueTo('140'); + expect(inputElm).toBeValid(); + expect(inputElm.val()).toBe('140'); + expect(scope.value).toBe(140); + expect(scope.form.alias.$error.max).toBeFalsy(); + + scope.$apply('max = null'); + expect(inputElm).toBeValid(); + expect(inputElm.val()).toBe('140'); + expect(scope.value).toBe(140); + expect(scope.form.alias.$error.max).toBeFalsy(); + + scope.$apply('max = "asdf"'); + expect(inputElm).toBeValid(); + expect(inputElm.val()).toBe('140'); + expect(scope.value).toBe(140); + expect(scope.form.alias.$error.max).toBeFalsy(); + }); + + it('should validate even if the max value changes on-the-fly', function() { + scope.max = 10; + var inputElm = helper.compileInput(''); + + helper.changeInputValueTo('5'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(5); + + scope.max = 0; + scope.$digest(); + expect(inputElm).toBeInvalid(); + expect(scope.value).toBeUndefined(); + expect(inputElm.val()).toBe('5'); + + scope.max = null; + scope.$digest(); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(5); + expect(inputElm.val()).toBe('5'); + + scope.max = '4'; + scope.$digest(); + expect(inputElm).toBeInvalid(); + expect(scope.value).toBeUndefined(); + expect(inputElm.val()).toBe('5'); + + scope.max = 'abc'; + scope.$digest(); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(5); + expect(inputElm.val()).toBe('5'); + }); + + it('should only validate once after compilation when inside ngRepeat', function() { + $rootScope.maxVal = 5; + $rootScope.value = 10; + helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.max).toBe(1); + }); + } + }); + + if (supportsRange) { + + describe('min and max', function() { + + it('should set the correct initial value when min and max are specified', function() { + scope.max = 80; + scope.min = 40; + var inputElm = helper.compileInput(''); + + expect(inputElm.val()).toBe('60'); + expect(scope.value).toBe(60); + }); + + it('should set element and model value to min if max is less than min', function() { + scope.min = 40; + var inputElm = helper.compileInput(''); + + expect(inputElm.val()).toBe('70'); + expect(scope.value).toBe(70); + + scope.max = 20; + scope.$digest(); + + expect(inputElm.val()).toBe('40'); + expect(scope.value).toBe(40); + }); + }); + } + + + describe('step', function() { + + if (supportsRange) { + // Browsers that implement range will never allow you to set a value that doesn't match the step value + // However, currently only Firefox fully implements the spec when setting the value after the step value changes. + // Other browsers fail in various edge cases, which is why they are not tested here. + + it('should round the input value to the nearest step on user input', function() { + var inputElm = helper.compileInput(''); + + helper.changeInputValueTo('5'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(5); + expect(scope.form.alias.$error.step).toBeFalsy(); + + helper.changeInputValueTo('10'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(10); + expect(scope.form.alias.$error.step).toBeFalsy(); + + helper.changeInputValueTo('9'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(10); + expect(scope.form.alias.$error.step).toBeFalsy(); + + helper.changeInputValueTo('7'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(5); + expect(scope.form.alias.$error.step).toBeFalsy(); + + helper.changeInputValueTo('7.5'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(10); + expect(scope.form.alias.$error.step).toBeFalsy(); + }); + + it('should round the input value to the nearest step when setting the model', function() { + var inputElm = helper.compileInput(''); + + scope.$apply('value = 10'); + expect(inputElm.val()).toBe('10'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(10); + expect(scope.form.alias.$error.step).toBeFalsy(); + + scope.$apply('value = 5'); + expect(inputElm.val()).toBe('5'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(5); + expect(scope.form.alias.$error.step).toBeFalsy(); + + scope.$apply('value = 7.5'); + expect(inputElm.val()).toBe('10'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(10); + expect(scope.form.alias.$error.step).toBeFalsy(); + + scope.$apply('value = 7'); + expect(inputElm.val()).toBe('5'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(5); + expect(scope.form.alias.$error.step).toBeFalsy(); + + scope.$apply('value = 9'); + expect(inputElm.val()).toBe('10'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(10); + expect(scope.form.alias.$error.step).toBeFalsy(); + }); + + it('should only validate once after compilation when inside ngRepeat', function() { + $rootScope.stepVal = 5; + $rootScope.value = 10; + helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.step).toBe(1); + }); + + } else { + + it('should validate if "range" is not implemented', function() { + scope.step = 10; + scope.value = 20; + var inputElm = helper.compileInput(''); + + expect(inputElm.val()).toBe('20'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(20); + expect(scope.form.alias.$error.step).toBeFalsy(); + + helper.changeInputValueTo('18'); + expect(inputElm).toBeInvalid(); + expect(inputElm.val()).toBe('18'); + expect(scope.value).toBeUndefined(); + expect(scope.form.alias.$error.step).toBeTruthy(); + + helper.changeInputValueTo('10'); + expect(inputElm).toBeValid(); + expect(inputElm.val()).toBe('10'); + expect(scope.value).toBe(10); + expect(scope.form.alias.$error.step).toBeFalsy(); + + scope.$apply('value = 12'); + expect(inputElm).toBeInvalid(); + expect(inputElm.val()).toBe('12'); + expect(scope.value).toBe(12); + expect(scope.form.alias.$error.step).toBeTruthy(); + }); + + it('should validate even if the step value changes on-the-fly', function() { + scope.step = 10; + var inputElm = helper.compileInput(''); + + helper.changeInputValueTo('10'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(10); + + // Step changes, but value matches + scope.$apply('step = 5'); + expect(inputElm.val()).toBe('10'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(10); + expect(scope.form.alias.$error.step).toBeFalsy(); + + // Step changes, value does not match + scope.$apply('step = 6'); + expect(inputElm).toBeInvalid(); + expect(scope.value).toBeUndefined(); + expect(inputElm.val()).toBe('10'); + expect(scope.form.alias.$error.step).toBeTruthy(); + + // null = valid + scope.$apply('step = null'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(10); + expect(inputElm.val()).toBe('10'); + expect(scope.form.alias.$error.step).toBeFalsy(); + + // Step val as string + scope.$apply('step = "7"'); + expect(inputElm).toBeInvalid(); + expect(scope.value).toBeUndefined(); + expect(inputElm.val()).toBe('10'); + expect(scope.form.alias.$error.step).toBeTruthy(); + + // unparsable string is ignored + scope.$apply('step = "abc"'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(10); + expect(inputElm.val()).toBe('10'); + expect(scope.form.alias.$error.step).toBeFalsy(); + }); + + it('should use the correct "step base" when `[min]` is specified', function() { + $rootScope.min = 5; + $rootScope.step = 10; + $rootScope.value = 10; + var inputElm = helper.compileInput( + ''); + var ngModel = inputElm.controller('ngModel'); + + expect(inputElm.val()).toBe('10'); + expect(inputElm).toBeInvalid(); + expect(ngModel.$error.step).toBe(true); + expect($rootScope.value).toBe(10); + + helper.changeInputValueTo('15'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(15); + + $rootScope.$apply('step = 3'); + expect(inputElm.val()).toBe('15'); + expect(inputElm).toBeInvalid(); + expect(ngModel.$error.step).toBe(true); + expect($rootScope.value).toBeUndefined(); + + helper.changeInputValueTo('8'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(8); + + $rootScope.$apply('min = 10; step = 20; value = 30'); + expect(inputElm.val()).toBe('30'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(30); + + $rootScope.$apply('min = 5'); + expect(inputElm.val()).toBe('30'); + expect(inputElm).toBeInvalid(); + expect(ngModel.$error.step).toBe(true); + expect($rootScope.value).toBeUndefined(); + + $rootScope.$apply('step = 0.00000001'); + expect(inputElm.val()).toBe('30'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(30); + + // 0.3 - 0.2 === 0.09999999999999998 + $rootScope.$apply('min = 0.2; step = 0.09999999999999998; value = 0.3'); + expect(inputElm.val()).toBe('0.3'); + expect(inputElm).toBeInvalid(); + expect(ngModel.$error.step).toBe(true); + expect($rootScope.value).toBeUndefined(); + }); + + it('should correctly validate even in cases where the JS floating point arithmetic fails', + function() { + $rootScope.step = 0.1; + var inputElm = helper.compileInput( + ''); + var ngModel = inputElm.controller('ngModel'); + + expect(inputElm.val()).toBe(''); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBeUndefined(); + + helper.changeInputValueTo('0.3'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(0.3); + + helper.changeInputValueTo('2.9999999999999996'); + expect(inputElm).toBeInvalid(); + expect(ngModel.$error.step).toBe(true); + expect($rootScope.value).toBeUndefined(); + + // 0.5 % 0.1 === 0.09999999999999998 + helper.changeInputValueTo('0.5'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(0.5); + + // 3.5 % 0.1 === 0.09999999999999981 + helper.changeInputValueTo('3.5'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(3.5); + + // 1.16 % 0.01 === 0.009999999999999896 + // 1.16 * 100 === 115.99999999999999 + $rootScope.step = 0.01; + helper.changeInputValueTo('1.16'); + expect(inputElm).toBeValid(); + expect($rootScope.value).toBe(1.16); + } + ); + } + }); + }); describe('email', function() { @@ -2288,14 +4674,103 @@ describe('input', function() { describe('EMAIL_REGEXP', function() { /* global EMAIL_REGEXP: false */ it('should validate email', function() { + /* basic functionality */ expect(EMAIL_REGEXP.test('a@b.com')).toBe(true); expect(EMAIL_REGEXP.test('a@b.museum')).toBe(true); expect(EMAIL_REGEXP.test('a@B.c')).toBe(true); + /* domain label separation, hyphen-minus, syntax */ + expect(EMAIL_REGEXP.test('a@b.c.')).toBe(false); expect(EMAIL_REGEXP.test('a@.b.c')).toBe(false); expect(EMAIL_REGEXP.test('a@-b.c')).toBe(false); expect(EMAIL_REGEXP.test('a@b-.c')).toBe(false); + expect(EMAIL_REGEXP.test('a@b-c')).toBe(true); + expect(EMAIL_REGEXP.test('a@-')).toBe(false); + expect(EMAIL_REGEXP.test('a@.')).toBe(false); + expect(EMAIL_REGEXP.test('a@host_name')).toBe(false); + /* leading or sole digit */ expect(EMAIL_REGEXP.test('a@3b.c')).toBe(true); + expect(EMAIL_REGEXP.test('a@3')).toBe(true); + /* TLD eMail address */ expect(EMAIL_REGEXP.test('a@b')).toBe(true); + /* domain valid characters */ + expect(EMAIL_REGEXP.test('a@abcdefghijklmnopqrstuvwxyz-ABCDEFGHIJKLMNOPQRSTUVWXYZ.0123456789')).toBe(true); + /* domain invalid characters */ + expect(EMAIL_REGEXP.test('a@')).toBe(false); + expect(EMAIL_REGEXP.test('a@ ')).toBe(false); + expect(EMAIL_REGEXP.test('a@!')).toBe(false); + expect(EMAIL_REGEXP.test('a@"')).toBe(false); + expect(EMAIL_REGEXP.test('a@#')).toBe(false); + expect(EMAIL_REGEXP.test('a@$')).toBe(false); + expect(EMAIL_REGEXP.test('a@%')).toBe(false); + expect(EMAIL_REGEXP.test('a@&')).toBe(false); + expect(EMAIL_REGEXP.test('a@\'')).toBe(false); + expect(EMAIL_REGEXP.test('a@(')).toBe(false); + expect(EMAIL_REGEXP.test('a@)')).toBe(false); + expect(EMAIL_REGEXP.test('a@*')).toBe(false); + expect(EMAIL_REGEXP.test('a@+')).toBe(false); + expect(EMAIL_REGEXP.test('a@,')).toBe(false); + expect(EMAIL_REGEXP.test('a@/')).toBe(false); + expect(EMAIL_REGEXP.test('a@:')).toBe(false); + expect(EMAIL_REGEXP.test('a@;')).toBe(false); + expect(EMAIL_REGEXP.test('a@<')).toBe(false); + expect(EMAIL_REGEXP.test('a@=')).toBe(false); + expect(EMAIL_REGEXP.test('a@>')).toBe(false); + expect(EMAIL_REGEXP.test('a@?')).toBe(false); + expect(EMAIL_REGEXP.test('a@@')).toBe(false); + expect(EMAIL_REGEXP.test('a@[')).toBe(false); + expect(EMAIL_REGEXP.test('a@\\')).toBe(false); + expect(EMAIL_REGEXP.test('a@]')).toBe(false); + expect(EMAIL_REGEXP.test('a@^')).toBe(false); + expect(EMAIL_REGEXP.test('a@_')).toBe(false); + expect(EMAIL_REGEXP.test('a@`')).toBe(false); + expect(EMAIL_REGEXP.test('a@{')).toBe(false); + expect(EMAIL_REGEXP.test('a@|')).toBe(false); + expect(EMAIL_REGEXP.test('a@}')).toBe(false); + expect(EMAIL_REGEXP.test('a@~')).toBe(false); + expect(EMAIL_REGEXP.test('a@İ')).toBe(false); + expect(EMAIL_REGEXP.test('a@ı')).toBe(false); + /* domain length, label and total */ + expect(EMAIL_REGEXP.test('a@xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx')).toBe(true); + expect(EMAIL_REGEXP.test('a@xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx')).toBe(false); + /* eslint-disable max-len */ + expect(EMAIL_REGEXP.test('a@xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx')).toBe(true); + expect(EMAIL_REGEXP.test('a@xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.x')).toBe(true); + expect(EMAIL_REGEXP.test('a@xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xx')).toBe(false); + expect(EMAIL_REGEXP.test('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xx')).toBe(true); + expect(EMAIL_REGEXP.test('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxx')).toBe(false); + /* eslint-enable */ + /* local-part valid characters and dot-atom syntax */ + expect(EMAIL_REGEXP.test('\'@x')).toBe(true); + expect(EMAIL_REGEXP.test('-!#$%&*+/0123456789=?ABCDEFGHIJKLMNOPQRSTUVWXYZ@x')).toBe(true); + expect(EMAIL_REGEXP.test('^_`abcdefghijklmnopqrstuvwxyz{|}~@x')).toBe(true); + expect(EMAIL_REGEXP.test('.@x')).toBe(false); + expect(EMAIL_REGEXP.test('\'.@x')).toBe(false); + expect(EMAIL_REGEXP.test('.\'@x')).toBe(false); + expect(EMAIL_REGEXP.test('\'.\'@x')).toBe(true); + /* local-part invalid characters */ + expect(EMAIL_REGEXP.test('@x')).toBe(false); + expect(EMAIL_REGEXP.test(' @x')).toBe(false); + expect(EMAIL_REGEXP.test('"@x')).toBe(false); + expect(EMAIL_REGEXP.test('(@x')).toBe(false); + expect(EMAIL_REGEXP.test(')@x')).toBe(false); + expect(EMAIL_REGEXP.test(',@x')).toBe(false); + expect(EMAIL_REGEXP.test(':@x')).toBe(false); + expect(EMAIL_REGEXP.test(';@x')).toBe(false); + expect(EMAIL_REGEXP.test('<@x')).toBe(false); + expect(EMAIL_REGEXP.test('>@x')).toBe(false); + expect(EMAIL_REGEXP.test('@@x')).toBe(false); + expect(EMAIL_REGEXP.test('[@x')).toBe(false); + expect(EMAIL_REGEXP.test('\\@x')).toBe(false); + expect(EMAIL_REGEXP.test(']@x')).toBe(false); + expect(EMAIL_REGEXP.test('İ@x')).toBe(false); + expect(EMAIL_REGEXP.test('ı@x')).toBe(false); + /* local-part size limit */ + expect(EMAIL_REGEXP.test('xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@x')).toBe(true); + expect(EMAIL_REGEXP.test('xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@x')).toBe(false); + /* content (local-part + ‘@’ + domain) is required */ + expect(EMAIL_REGEXP.test('')).toBe(false); + expect(EMAIL_REGEXP.test('a')).toBe(false); + expect(EMAIL_REGEXP.test('aa')).toBe(false); }); }); }); @@ -2320,10 +4795,115 @@ describe('input', function() { describe('URL_REGEXP', function() { - /* global URL_REGEXP: false */ - it('should validate url', function() { - expect(URL_REGEXP.test('http://server:123/path')).toBe(true); - expect(URL_REGEXP.test('a@B.c')).toBe(false); + // See valid URLs in RFC3987 (http://tools.ietf.org/html/rfc3987) + // Note: We are being more lenient, because browsers are too. + var urls = [ + ['scheme://hostname', true], + ['scheme://username:password@host.name:7678/pa/t.h?q=u&e=r&y#fragment', true], + + // Validating `scheme` + ['://example.com', false], + ['0scheme://example.com', false], + ['.scheme://example.com', false], + ['+scheme://example.com', false], + ['-scheme://example.com', false], + ['_scheme://example.com', false], + ['scheme0://example.com', true], + ['scheme.://example.com', true], + ['scheme+://example.com', true], + ['scheme-://example.com', true], + ['scheme_://example.com', false], + + // Validating `:` and `/` after `scheme` + ['scheme//example.com', false], + ['scheme:example.com', true], + ['scheme:/example.com', true], + ['scheme:///example.com', true], + + // Validating `username` and `password` + ['scheme://@example.com', true], + ['scheme://username@example.com', true], + ['scheme://u0s.e+r-n_a~m!e@example.com', true], + ['scheme://u#s$e%r^n&a*m;e@example.com', true], + ['scheme://:password@example.com', true], + ['scheme://username:password@example.com', true], + ['scheme://username:pass:word@example.com', true], + ['scheme://username:p0a.s+s-w_o~r!d@example.com', true], + ['scheme://username:p#a$s%s^w&o*r;d@example.com', true], + + // Validating `hostname` + ['scheme:', false], // Chrome, FF: true + ['scheme://', false], // Chrome, FF: true + ['scheme:// example.com:', false], // Chrome, FF: true + ['scheme://example com:', false], // Chrome, FF: true + ['scheme://:', false], // Chrome, FF: true + ['scheme://?', false], // Chrome, FF: true + ['scheme://#', false], // Chrome, FF: true + ['scheme://username:password@:', false], // Chrome, FF: true + ['scheme://username:password@/', false], // Chrome, FF: true + ['scheme://username:password@?', false], // Chrome, FF: true + ['scheme://username:password@#', false], // Chrome, FF: true + ['scheme://host.name', true], + ['scheme://123.456.789.10', true], + ['scheme://[1234:0000:0000:5678:9abc:0000:0000:def]', true], + ['scheme://[1234:0000:0000:5678:9abc:0000:0000:def]:7678', true], + ['scheme://[1234:0:0:5678:9abc:0:0:def]', true], + ['scheme://[1234::5678:9abc::def]', true], + ['scheme://~`!@$%^&*-_=+|\\;\'",.()[]{}<>', true], + + // Validating `port` + ['scheme://example.com/no-port', true], + ['scheme://example.com:7678', true], + ['scheme://example.com:76T8', false], // Chrome, FF: true + ['scheme://example.com:port', false], // Chrome, FF: true + + // Validating `path` + ['scheme://example.com/', true], + ['scheme://example.com/path', true], + ['scheme://example.com/path/~`!@$%^&*-_=+|\\;:\'",./()[]{}<>', true], + + // Validating `query` + ['scheme://example.com?query', true], + ['scheme://example.com/?query', true], + ['scheme://example.com/path?query', true], + ['scheme://example.com/path?~`!@$%^&*-_=+|\\;:\'",.?/()[]{}<>', true], + + // Validating `fragment` + ['scheme://example.com#fragment', true], + ['scheme://example.com/#fragment', true], + ['scheme://example.com/path#fragment', true], + ['scheme://example.com/path/#fragment', true], + ['scheme://example.com/path?query#fragment', true], + ['scheme://example.com/path?query#~`!@#$%^&*-_=+|\\;:\'",.?/()[]{}<>', true], + + // Validating miscellaneous + ['scheme://☺.✪.⌘.➡/䨹', true], + ['scheme://مثال.إختبار', true], + ['scheme://例子.测试', true], + ['scheme://उदाहरण.परीक्षा', true], + + // Legacy tests + ['http://server:123/path', true], + ['https://server:123/path', true], + ['file:///home/user', true], + ['mailto:user@example.com?subject=Foo', true], + ['r2-d2.c3-p0://localhost/foo', true], + ['abc:/foo', true], + ['http://example.com/path;path', true], + ['http://example.com/[]$/'()*,~)', true], + ['http:', false], // FF: true + ['a@B.c', false], + ['a_B.c', false], + ['0scheme://example.com', false], + ['http://example.com:9999/%60%60', true] + ]; + + they('should validate url: $prop', urls, function(item) { + var url = item[0]; + var valid = item[1]; + + /* global URL_REGEXP: false */ + expect(URL_REGEXP.test(url)).toBe(valid); }); }); }); @@ -2331,26 +4911,38 @@ describe('input', function() { describe('radio', function() { - it('should update the model', function() { + they('should update the model on $prop event', ['click', 'change'], function(event) { var inputElm = helper.compileInput( '' + '' + ''); - $rootScope.$apply("color = 'white'"); + $rootScope.$apply('color = \'white\''); expect(inputElm[0].checked).toBe(true); expect(inputElm[1].checked).toBe(false); expect(inputElm[2].checked).toBe(false); - $rootScope.$apply("color = 'red'"); + $rootScope.$apply('color = \'red\''); expect(inputElm[0].checked).toBe(false); expect(inputElm[1].checked).toBe(true); expect(inputElm[2].checked).toBe(false); - browserTrigger(inputElm[2], 'click'); + if (event === 'change') inputElm[2].checked = true; + browserTrigger(inputElm[2], event); expect($rootScope.color).toBe('blue'); }); + it('should treat the value as a string when evaluating checked-ness', function() { + var inputElm = helper.compileInput( + ''); + + $rootScope.$apply('model = \'0\''); + expect(inputElm[0].checked).toBe(true); + + $rootScope.$apply('model = 0'); + expect(inputElm[0].checked).toBe(false); + }); + it('should allow {{expr}} as value', function() { $rootScope.some = 11; @@ -2370,11 +4962,58 @@ describe('input', function() { browserTrigger(inputElm[1], 'click'); expect($rootScope.value).toBe('red'); - $rootScope.$apply("other = 'non-red'"); + $rootScope.$apply('other = \'non-red\''); expect(inputElm[0].checked).toBe(false); expect(inputElm[1].checked).toBe(false); }); + + + it('should allow the use of ngTrim', function() { + $rootScope.some = 11; + var inputElm = helper.compileInput( + '' + + '' + + '' + + '' + + ''); + + $rootScope.$apply(function() { + $rootScope.value = 'blue'; + $rootScope.some = 'blue'; + }); + + expect(inputElm[0].checked).toBe(false); + expect(inputElm[1].checked).toBe(false); + expect(inputElm[2].checked).toBe(false); + expect(inputElm[3].checked).toBe(true); + expect(inputElm[4].checked).toBe(false); + + browserTrigger(inputElm[1], 'click'); + expect($rootScope.value).toBe('opt2'); + browserTrigger(inputElm[2], 'click'); + expect($rootScope.value).toBe(' opt3 '); + browserTrigger(inputElm[3], 'click'); + expect($rootScope.value).toBe('blue'); + browserTrigger(inputElm[4], 'click'); + expect($rootScope.value).toBe(' blue '); + + $rootScope.$apply('value = \' opt2 \''); + expect(inputElm[1].checked).toBe(false); + $rootScope.$apply('value = \'opt2\''); + expect(inputElm[1].checked).toBe(true); + $rootScope.$apply('value = \' opt3 \''); + expect(inputElm[2].checked).toBe(true); + $rootScope.$apply('value = \'opt3\''); + expect(inputElm[2].checked).toBe(false); + + $rootScope.$apply('value = \'blue\''); + expect(inputElm[3].checked).toBe(true); + expect(inputElm[4].checked).toBe(false); + $rootScope.$apply('value = \' blue \''); + expect(inputElm[3].checked).toBe(false); + expect(inputElm[4].checked).toBe(true); + }); }); @@ -2391,13 +5030,30 @@ describe('input', function() { }); + they('should update the model on $prop event', ['click', 'change'], function(event) { + var inputElm = helper.compileInput(''); + + expect(inputElm[0].checked).toBe(false); + + $rootScope.$apply('checkbox = true'); + expect(inputElm[0].checked).toBe(true); + + $rootScope.$apply('checkbox = false'); + expect(inputElm[0].checked).toBe(false); + + if (event === 'change') inputElm[0].checked = true; + browserTrigger(inputElm[0], event); + expect($rootScope.checkbox).toBe(true); + }); + + it('should format booleans', function() { var inputElm = helper.compileInput(''); - $rootScope.$apply("name = false"); + $rootScope.$apply('name = false'); expect(inputElm[0].checked).toBe(false); - $rootScope.$apply("name = true"); + $rootScope.$apply('name = true'); expect(inputElm[0].checked).toBe(true); }); @@ -2417,13 +5073,13 @@ describe('input', function() { var inputElm = helper.compileInput(''); - $rootScope.$apply("name = 'y'"); + $rootScope.$apply('name = \'y\''); expect(inputElm[0].checked).toBe(true); - $rootScope.$apply("name = 'n'"); + $rootScope.$apply('name = \'n\''); expect(inputElm[0].checked).toBe(false); - $rootScope.$apply("name = 'something else'"); + $rootScope.$apply('name = \'something else\''); expect(inputElm[0].checked).toBe(false); browserTrigger(inputElm, 'click'); @@ -2437,14 +5093,14 @@ describe('input', function() { it('should throw if ngTrueValue is present and not a constant expression', function() { expect(function() { var inputElm = helper.compileInput(''); - }).toThrowMinErr('ngModel', 'constexpr', "Expected constant expression for `ngTrueValue`, but saw `yes`."); + }).toThrowMinErr('ngModel', 'constexpr', 'Expected constant expression for `ngTrueValue`, but saw `yes`.'); }); it('should throw if ngFalseValue is present and not a constant expression', function() { expect(function() { var inputElm = helper.compileInput(''); - }).toThrowMinErr('ngModel', 'constexpr', "Expected constant expression for `ngFalseValue`, but saw `no`."); + }).toThrowMinErr('ngModel', 'constexpr', 'Expected constant expression for `ngFalseValue`, but saw `no`.'); }); @@ -2468,24 +5124,27 @@ describe('input', function() { }); - it('should set the ngTrueValue when required directive is present', function() { - var inputElm = helper.compileInput(''); + it('should pass validation for "required" when trueValue is a string', function() { + var inputElm = helper.compileInput(''); expect(inputElm).toBeInvalid(); + expect($rootScope.form.cb.$error.required).toBe(true); browserTrigger(inputElm, 'click'); expect(inputElm[0].checked).toBe(true); expect(inputElm).toBeValid(); + expect($rootScope.form.cb.$error.required).toBeUndefined(); }); }); describe('textarea', function() { - it("should process textarea", function() { + it('should process textarea', function() { var inputElm = helper.compileInput(''); - $rootScope.$apply("name = 'Adam'"); + $rootScope.$apply('name = \'Adam\''); expect(inputElm.val()).toEqual('Adam'); helper.changeInputValueTo('Shyam'); @@ -2513,12 +5172,52 @@ describe('input', function() { it('should update the dom "value" property and attribute', function() { var inputElm = helper.compileInput(''); - $rootScope.$apply("value = 'something'"); + $rootScope.$apply('value = \'something\''); expect(inputElm[0].value).toBe('something'); expect(inputElm[0].getAttribute('value')).toBe('something'); }); + it('should clear the "dom" value property and attribute when the value is undefined', function() { + var inputElm = helper.compileInput(''); + + $rootScope.$apply('value = "something"'); + + expect(inputElm[0].value).toBe('something'); + expect(inputElm[0].getAttribute('value')).toBe('something'); + + $rootScope.$apply(function() { + delete $rootScope.value; + }); + + expect(inputElm[0].value).toBe(''); + // Support: IE 9-11, Edge + // In IE it is not possible to remove the `value` attribute from an input element. + if (!msie && !isEdge) { + expect(inputElm[0].getAttribute('value')).toBeNull(); + } else { + // Support: IE 9-11, Edge + // This will fail if the Edge bug gets fixed + expect(inputElm[0].getAttribute('value')).toBe('something'); + } + }); + + they('should update the $prop "value" property and attribute after the bound expression changes', { + input: '', + textarea: '' + }, function(tmpl) { + var element = helper.compileInput(tmpl); + + helper.changeInputValueTo('newValue'); + expect(element[0].value).toBe('newValue'); + expect(element[0].getAttribute('value')).toBeNull(); + + $rootScope.$apply(function() { + $rootScope.value = 'anotherValue'; + }); + expect(element[0].value).toBe('anotherValue'); + expect(element[0].getAttribute('value')).toBe('anotherValue'); + }); it('should evaluate and set constant expressions', function() { var inputElm = helper.compileInput('' + @@ -2536,6 +5235,18 @@ describe('input', function() { }); + it('should use strict comparison between model and value', function() { + $rootScope.selected = false; + var inputElm = helper.compileInput('' + + '' + + ''); + + expect(inputElm[0].checked).toBe(true); + expect(inputElm[1].checked).toBe(false); + expect(inputElm[2].checked).toBe(false); + }); + + it('should watch the expression', function() { var inputElm = helper.compileInput(''); diff --git a/test/ng/directive/ngBindSpec.js b/test/ng/directive/ngBindSpec.js index c03afa9123bb..1d5cba43415e 100644 --- a/test/ng/directive/ngBindSpec.js +++ b/test/ng/directive/ngBindSpec.js @@ -46,6 +46,43 @@ describe('ngBind*', function() { expect(element.text()).toEqual('-0false'); })); + they('should jsonify $prop', [[{a: 1}, '{"a":1}'], [true, 'true'], [false, 'false']], function(prop) { + inject(function($rootScope, $compile) { + $rootScope.value = prop[0]; + element = $compile('
')($rootScope); + $rootScope.$digest(); + expect(element.text()).toEqual(prop[1]); + }); + }); + + it('should use custom toString when present', inject(function($rootScope, $compile) { + $rootScope.value = { + toString: function() { + return 'foo'; + } + }; + element = $compile('
')($rootScope); + $rootScope.$digest(); + expect(element.text()).toEqual('foo'); + })); + + it('should NOT use toString on array objects', inject(function($rootScope, $compile) { + $rootScope.value = []; + element = $compile('
')($rootScope); + $rootScope.$digest(); + expect(element.text()).toEqual('[]'); + })); + + + it('should NOT use toString on Date objects', inject(function($rootScope, $compile) { + $rootScope.value = new Date(2014, 10, 10, 0, 0, 0); + element = $compile('
')($rootScope); + $rootScope.$digest(); + expect(element.text()).toBe(JSON.stringify($rootScope.value)); + expect(element.text()).not.toEqual($rootScope.value.toString()); + })); + + it('should one-time bind if the expression starts with two colons', inject(function($rootScope, $compile) { element = $compile('
')($rootScope); $rootScope.a = 'lucas'; @@ -126,7 +163,7 @@ describe('ngBind*', function() { expect(function() { $compile('
'); }).toThrowMinErr('$parse', 'syntax', - "Syntax Error: Token '{' invalid key at column 2 of the expression [{{myHtml}}] starting at [{myHtml}}]"); + 'Syntax Error: Token \'{\' invalid key at column 2 of the expression [{{myHtml}}] starting at [{myHtml}}]'); })); @@ -139,7 +176,17 @@ describe('ngBind*', function() { element = $compile('
')($rootScope); $rootScope.html = '
hello
'; $rootScope.$digest(); - expect(angular.lowercase(element.html())).toEqual('
hello
'); + expect(lowercase(element.html())).toEqual('
hello
'); + })); + + it('should update html', inject(function($rootScope, $compile, $sce) { + element = $compile('
')($rootScope); + $rootScope.html = 'hello'; + $rootScope.$digest(); + expect(lowercase(element.html())).toEqual('hello'); + $rootScope.html = 'goodbye'; + $rootScope.$digest(); + expect(lowercase(element.html())).toEqual('goodbye'); })); it('should one-time bind if the expression starts with two colons', inject(function($rootScope, $compile) { @@ -173,10 +220,21 @@ describe('ngBind*', function() { element = $compile('
')($rootScope); $rootScope.html = $sce.trustAsHtml('
hello
'); $rootScope.$digest(); - expect(angular.lowercase(element.html())).toEqual('
hello
'); + expect(lowercase(element.html())).toEqual('
hello
'); + })); + + it('should update html', inject(function($rootScope, $compile, $sce) { + element = $compile('
')($rootScope); + $rootScope.html = $sce.trustAsHtml('hello'); + $rootScope.$digest(); + expect(lowercase(element.html())).toEqual('hello'); + $rootScope.html = $sce.trustAsHtml('goodbye'); + $rootScope.$digest(); + expect(lowercase(element.html())).toEqual('goodbye'); })); - it('should watch the string value to avoid infinite recursion', inject(function($rootScope, $compile, $sce) { + it('should not cause infinite recursion for trustAsHtml object watches', + inject(function($rootScope, $compile, $sce) { // Ref: https://github.com/angular/angular.js/issues/3932 // If the binding is a function that creates a new value on every call via trustAs, we'll // trigger an infinite digest if we don't take care of it. @@ -185,9 +243,36 @@ describe('ngBind*', function() { return $sce.trustAsHtml('
hello
'); }; $rootScope.$digest(); - expect(angular.lowercase(element.html())).toEqual('
hello
'); + expect(lowercase(element.html())).toEqual('
hello
'); })); + it('should handle custom $sce objects', function() { + function MySafeHtml(val) { this.val = val; } + + module(function($provide) { + $provide.decorator('$sce', function($delegate) { + $delegate.trustAsHtml = function(html) { return new MySafeHtml(html); }; + $delegate.getTrustedHtml = function(mySafeHtml) { return mySafeHtml.val; }; + $delegate.valueOf = function(v) { return v instanceof MySafeHtml ? v.val : v; }; + return $delegate; + }); + }); + + inject(function($rootScope, $compile, $sce) { + // Ref: https://github.com/angular/angular.js/issues/14526 + // Previous code used toString for change detection, which fails for custom objects + // that don't override toString. + element = $compile('
')($rootScope); + var html = 'hello'; + $rootScope.getHtml = function() { return $sce.trustAsHtml(html); }; + $rootScope.$digest(); + expect(lowercase(element.html())).toEqual('hello'); + html = 'goodbye'; + $rootScope.$digest(); + expect(lowercase(element.html())).toEqual('goodbye'); + }); + }); + describe('when $sanitize is available', function() { beforeEach(function() { module('ngSanitize'); }); @@ -195,7 +280,7 @@ describe('ngBind*', function() { element = $compile('
')($rootScope); $rootScope.html = '
hello
'; $rootScope.$digest(); - expect(angular.lowercase(element.html())).toEqual('
hello
'); + expect(lowercase(element.html())).toEqual('
hello
'); })); }); }); diff --git a/test/ng/directive/ngChangeSpec.js b/test/ng/directive/ngChangeSpec.js index fc4990e14425..8e67328df09a 100644 --- a/test/ng/directive/ngChangeSpec.js +++ b/test/ng/directive/ngChangeSpec.js @@ -1,19 +1,12 @@ 'use strict'; -/* globals getInputCompileHelper: false */ +/* globals generateInputCompilerHelper: false */ describe('ngChange', function() { - var helper, $rootScope; - - beforeEach(function() { - helper = getInputCompileHelper(this); - }); - - afterEach(function() { - helper.dealoc(); - }); + var helper = {}, $rootScope; + generateInputCompilerHelper(helper); beforeEach(inject(function(_$rootScope_) { $rootScope = _$rootScope_; @@ -22,7 +15,7 @@ describe('ngChange', function() { it('should $eval expression after new value is set in the model', function() { helper.compileInput(''); - $rootScope.change = jasmine.createSpy('change').andCallFake(function() { + $rootScope.change = jasmine.createSpy('change').and.callFake(function() { expect($rootScope.value).toBe('new value'); }); diff --git a/test/ng/directive/ngClassSpec.js b/test/ng/directive/ngClassSpec.js index 2b174dcc2a91..74500505fd84 100644 --- a/test/ng/directive/ngClassSpec.js +++ b/test/ng/directive/ngClassSpec.js @@ -33,6 +33,31 @@ describe('ngClass', function() { })); + it('should add new and remove old classes with same names as Object.prototype properties dynamically', inject(function($rootScope, $compile) { + element = $compile('
')($rootScope); + $rootScope.dynClass = { watch: true, hasOwnProperty: true, isPrototypeOf: true }; + $rootScope.$digest(); + expect(element.hasClass('existing')).toBe(true); + expect(element.hasClass('watch')).toBe(true); + expect(element.hasClass('hasOwnProperty')).toBe(true); + expect(element.hasClass('isPrototypeOf')).toBe(true); + + $rootScope.dynClass.watch = false; + $rootScope.$digest(); + expect(element.hasClass('existing')).toBe(true); + expect(element.hasClass('watch')).toBe(false); + expect(element.hasClass('hasOwnProperty')).toBe(true); + expect(element.hasClass('isPrototypeOf')).toBe(true); + + delete $rootScope.dynClass; + $rootScope.$digest(); + expect(element.hasClass('existing')).toBe(true); + expect(element.hasClass('watch')).toBe(false); + expect(element.hasClass('hasOwnProperty')).toBe(false); + expect(element.hasClass('isPrototypeOf')).toBe(false); + })); + + it('should support adding multiple classes via an array', inject(function($rootScope, $compile) { element = $compile('
')($rootScope); $rootScope.$digest(); @@ -63,6 +88,23 @@ describe('ngClass', function() { expect(element.hasClass('AnotB')).toBeFalsy(); })); + it('should not break when passed non-string/array/object, truthy values', inject(function($rootScope, $compile) { + element = $compile('
')($rootScope); + $rootScope.$digest(); + expect(element.hasClass('42')).toBeTruthy(); + })); + + it('should support adding multiple classes via an array mixed with conditionally via a map', inject(function($rootScope, $compile) { + element = $compile('
')($rootScope); + $rootScope.$digest(); + expect(element.hasClass('existing')).toBeTruthy(); + expect(element.hasClass('A')).toBeTruthy(); + expect(element.hasClass('B')).toBeFalsy(); + $rootScope.condition = true; + $rootScope.$digest(); + expect(element.hasClass('B')).toBeTruthy(); + + })); it('should remove classes when the referenced object is the same but its property is changed', inject(function($rootScope, $compile) { @@ -208,21 +250,34 @@ describe('ngClass', function() { })); - it("should allow ngClassOdd/Even on the same element with overlapping classes", inject(function($rootScope, $compile, $animate) { - var className; - - element = $compile('
    • ')($rootScope); + it('should allow ngClassOdd/Even on the same element with overlapping classes', + inject(function($compile, $rootScope) { + element = $compile( + '
        ' + + '
      • ' + + '
      • ' + + '
          ')($rootScope); $rootScope.$digest(); - var e1 = jqLite(element[0].childNodes[1]); - var e2 = jqLite(element[0].childNodes[5]); - expect(e1.hasClass('same')).toBeTruthy(); - expect(e1.hasClass('odd')).toBeTruthy(); - expect(e2.hasClass('same')).toBeTruthy(); - expect(e2.hasClass('odd')).toBeTruthy(); + + var e1 = element.children().eq(0); + var e2 = element.children().eq(1); + var e3 = element.children().eq(2); + + expect(e1).toHaveClass('same'); + expect(e1).toHaveClass('odd'); + expect(e1).not.toHaveClass('even'); + expect(e2).toHaveClass('same'); + expect(e2).not.toHaveClass('odd'); + expect(e2).toHaveClass('even'); + expect(e3).toHaveClass('same'); + expect(e3).toHaveClass('odd'); + expect(e3).not.toHaveClass('even'); }) ); - it('should allow ngClass with overlapping classes', inject(function($rootScope, $compile, $animate) { + it('should allow ngClass with overlapping classes', inject(function($rootScope, $compile) { element = $compile('
          ')($rootScope); $rootScope.$digest(); @@ -230,9 +285,7 @@ describe('ngClass', function() { expect(element).not.toHaveClass('yes'); expect(element).toHaveClass('no'); - $rootScope.$apply(function() { - $rootScope.test = true; - }); + $rootScope.$apply('test = true'); expect(element).toHaveClass('same'); expect(element).toHaveClass('yes'); @@ -263,43 +316,84 @@ describe('ngClass', function() { expect(e2.hasClass('D')).toBeFalsy(); })); - - it('should reapply ngClass when interpolated class attribute changes', inject(function($rootScope, $compile) { - element = $compile('
          ')($rootScope); - - $rootScope.$apply(function() { - $rootScope.cls = "two"; - $rootScope.four = true; - }); - expect(element).toHaveClass('one'); - expect(element).toHaveClass('two'); // interpolated - expect(element).toHaveClass('three'); - expect(element).toHaveClass('four'); - - $rootScope.$apply(function() { - $rootScope.cls = "too"; - }); - expect(element).toHaveClass('one'); - expect(element).toHaveClass('too'); // interpolated - expect(element).toHaveClass('three'); - expect(element).toHaveClass('four'); // should still be there - expect(element.hasClass('two')).toBeFalsy(); - - $rootScope.$apply(function() { - $rootScope.cls = "to"; - }); - expect(element).toHaveClass('one'); - expect(element).toHaveClass('to'); // interpolated - expect(element).toHaveClass('three'); - expect(element).toHaveClass('four'); // should still be there - expect(element.hasClass('two')).toBeFalsy(); - expect(element.hasClass('too')).toBeFalsy(); - })); + it('should reapply ngClass when interpolated class attribute changes', + inject(function($compile, $rootScope) { + element = $compile( + '
          ' + + '
          ' + + '
          ' + + '
          ')($rootScope); + var e1 = element.children().eq(0); + var e2 = element.children().eq(1); + + $rootScope.$apply('two = "two"; five = true'); + + expect(e1).toHaveClass('one'); + expect(e1).toHaveClass('two'); + expect(e1).toHaveClass('three'); + expect(e1).not.toHaveClass('four'); + expect(e1).toHaveClass('five'); + expect(e2).toHaveClass('one'); + expect(e2).toHaveClass('two'); + expect(e2).toHaveClass('three'); + expect(e2).not.toHaveClass('four'); + expect(e2).toHaveClass('five'); + + $rootScope.$apply('two = "another-two"'); + + expect(e1).toHaveClass('one'); + expect(e1).not.toHaveClass('two'); + expect(e1).toHaveClass('another-two'); + expect(e1).toHaveClass('three'); + expect(e1).not.toHaveClass('four'); + expect(e1).toHaveClass('five'); + expect(e2).toHaveClass('one'); + expect(e2).not.toHaveClass('two'); + expect(e2).toHaveClass('another-two'); + expect(e2).toHaveClass('three'); + expect(e2).not.toHaveClass('four'); + expect(e2).toHaveClass('five'); + + $rootScope.$apply('two = "two-more"; four = "four"'); + + expect(e1).toHaveClass('one'); + expect(e1).not.toHaveClass('two'); + expect(e1).not.toHaveClass('another-two'); + expect(e1).toHaveClass('two-more'); + expect(e1).toHaveClass('three'); + expect(e1).not.toHaveClass('four'); + expect(e1).toHaveClass('five'); + expect(e2).toHaveClass('one'); + expect(e2).not.toHaveClass('two'); + expect(e2).not.toHaveClass('another-two'); + expect(e2).toHaveClass('two-more'); + expect(e2).toHaveClass('three'); + expect(e2).toHaveClass('four'); + expect(e2).toHaveClass('five'); + + $rootScope.$apply('five = false'); + + expect(e1).toHaveClass('one'); + expect(e1).not.toHaveClass('two'); + expect(e1).not.toHaveClass('another-two'); + expect(e1).toHaveClass('two-more'); + expect(e1).toHaveClass('three'); + expect(e1).not.toHaveClass('four'); + expect(e1).not.toHaveClass('five'); + expect(e2).toHaveClass('one'); + expect(e2).not.toHaveClass('two'); + expect(e2).not.toHaveClass('another-two'); + expect(e2).toHaveClass('two-more'); + expect(e2).toHaveClass('three'); + expect(e2).toHaveClass('four'); + expect(e2).not.toHaveClass('five'); + }) + ); it('should not mess up class value due to observing an interpolated class attribute', inject(function($rootScope, $compile) { $rootScope.foo = true; - $rootScope.$watch("anything", function() { + $rootScope.$watch('anything', function() { $rootScope.foo = false; }); element = $compile('
          ')($rootScope); @@ -372,6 +466,232 @@ describe('ngClass', function() { expect(e2.hasClass('even')).toBeTruthy(); expect(e2.hasClass('odd')).toBeFalsy(); })); + + + it('should add/remove the correct classes when the expression and `$index` change simultaneously', + inject(function($compile, $rootScope) { + element = $compile( + '
          ' + + '
          ' + + '
          ' + + '
          ')($rootScope); + var odd = element.children().eq(0); + var even = element.children().eq(1); + + $rootScope.$apply('$index = 0; foo = "class1"'); + + expect(odd).toHaveClass('class1'); + expect(odd).not.toHaveClass('class2'); + expect(even).not.toHaveClass('class1'); + expect(even).not.toHaveClass('class2'); + + $rootScope.$apply('$index = 1; foo = "class2"'); + + expect(odd).not.toHaveClass('class1'); + expect(odd).not.toHaveClass('class2'); + expect(even).not.toHaveClass('class1'); + expect(even).toHaveClass('class2'); + + $rootScope.$apply('foo = "class1"'); + + expect(odd).not.toHaveClass('class1'); + expect(odd).not.toHaveClass('class2'); + expect(even).toHaveClass('class1'); + expect(even).not.toHaveClass('class2'); + + $rootScope.$apply('$index = 2'); + + expect(odd).toHaveClass('class1'); + expect(odd).not.toHaveClass('class2'); + expect(even).not.toHaveClass('class1'); + expect(even).not.toHaveClass('class2'); + }) + ); + + it('should support mixed array/object variable with a mutating object', + inject(function($rootScope, $compile) { + element = $compile('
          ')($rootScope); + + $rootScope.classVar = [{orange: true}]; + $rootScope.$digest(); + expect(element).toHaveClass('orange'); + + $rootScope.classVar[0].orange = false; + $rootScope.$digest(); + + expect(element).not.toHaveClass('orange'); + }) + ); + + // https://github.com/angular/angular.js/issues/15905 + it('should support a mixed literal-array/object variable', inject(function($rootScope, $compile) { + element = $compile('
          ')($rootScope); + + $rootScope.classVar = {orange: true}; + $rootScope.$digest(); + expect(element).toHaveClass('orange'); + + $rootScope.classVar.orange = false; + $rootScope.$digest(); + + expect(element).not.toHaveClass('orange'); + }) + ); + + it('should support a one-time mixed literal-array/object variable', inject(function($rootScope, $compile) { + element = $compile('
          ')($rootScope); + + $rootScope.classVar1 = {orange: true}; + $rootScope.$digest(); + expect(element).toHaveClass('orange'); + + $rootScope.classVar1.orange = false; + $rootScope.$digest(); + + expect(element).not.toHaveClass('orange'); + }) + ); + + + it('should do value stabilization as expected when one-time binding', + inject(function($rootScope, $compile) { + element = $compile('
          ')($rootScope); + + $rootScope.$apply('className = "foo"'); + expect(element).toHaveClass('foo'); + + $rootScope.$apply('className = "bar"'); + expect(element).toHaveClass('foo'); + }) + ); + + it('should remove the watcher when static array one-time binding', + inject(function($rootScope, $compile) { + element = $compile('
          ')($rootScope); + + $rootScope.$apply('className = "foo"'); + expect(element).toHaveClass('foo'); + + $rootScope.$apply('className = "bar"'); + expect(element).toHaveClass('foo'); + expect(element).not.toHaveClass('bar'); + }) + ); + + it('should remove the watcher when static map one-time binding', + inject(function($rootScope, $compile) { + element = $compile('
          ')($rootScope); + + $rootScope.$apply('fooPresent = true'); + expect(element).toHaveClass('foo'); + + $rootScope.$apply('fooPresent = false'); + expect(element).toHaveClass('foo'); + }) + ); + + it('should track changes of mutating object inside an array', + inject(function($rootScope, $compile) { + $rootScope.classVar = [{orange: true}]; + element = $compile('
          ')($rootScope); + + $rootScope.$digest(); + expect(element).toHaveClass('orange'); + + $rootScope.$apply('classVar[0].orange = false'); + expect(element).not.toHaveClass('orange'); + }) + ); + + //https://github.com/angular/angular.js/issues/15960#issuecomment-299109412 + it('should always reevaluate filters with non-primitive inputs within literals', function() { + module(function($filterProvider) { + $filterProvider.register('foo', valueFn(function(o) { + return o.a || o.b; + })); + }); + + inject(function($rootScope, $compile) { + $rootScope.testObj = {}; + element = $compile('
          ')($rootScope); + + $rootScope.$apply(); + expect(element).not.toHaveClass('x'); + + $rootScope.$apply('testObj.a = true'); + expect(element).toHaveClass('x'); + }); + }); + + describe('large objects', function() { + var getProp; + var veryLargeObj; + + beforeEach(function() { + getProp = jasmine.createSpy('getProp'); + veryLargeObj = {}; + + Object.defineProperty(veryLargeObj, 'prop', { + get: getProp, + enumerable: true + }); + }); + + it('should not be copied when using an expression', inject(function($compile, $rootScope) { + element = $compile('
          ')($rootScope); + $rootScope.fooClass = {foo: veryLargeObj}; + $rootScope.$digest(); + + expect(element).toHaveClass('foo'); + expect(getProp).not.toHaveBeenCalled(); + })); + + it('should not be copied when using a literal', inject(function($compile, $rootScope) { + element = $compile('
          ')($rootScope); + $rootScope.veryLargeObj = veryLargeObj; + $rootScope.$digest(); + + expect(element).toHaveClass('foo'); + expect(getProp).not.toHaveBeenCalled(); + })); + + it('should not be copied when inside an array', inject(function($compile, $rootScope) { + element = $compile('
          ')($rootScope); + $rootScope.veryLargeObj = veryLargeObj; + $rootScope.$digest(); + + expect(element).toHaveClass('foo'); + expect(getProp).not.toHaveBeenCalled(); + })); + + it('should not be copied when using one-time binding', inject(function($compile, $rootScope) { + element = $compile('
          ')($rootScope); + $rootScope.veryLargeObj = veryLargeObj; + $rootScope.$digest(); + + expect(element).toHaveClass('foo'); + expect(element).not.toHaveClass('bar'); + expect(getProp).not.toHaveBeenCalled(); + + $rootScope.$apply('veryLargeObj.bar = "bar"'); + + expect(element).toHaveClass('foo'); + expect(element).not.toHaveClass('bar'); + expect(getProp).not.toHaveBeenCalled(); + + $rootScope.$apply('bar = "bar"'); + + expect(element).toHaveClass('foo'); + expect(element).toHaveClass('bar'); + expect(getProp).not.toHaveBeenCalled(); + + $rootScope.$apply('veryLargeObj.bar = "qux"'); + + expect(element).toHaveClass('foo'); + expect(element).toHaveClass('bar'); + expect(getProp).not.toHaveBeenCalled(); + })); + }); }); describe('ngClass animations', function() { @@ -381,11 +701,11 @@ describe('ngClass animations', function() { dealoc(element); }); - it("should avoid calling addClass accidentally when removeClass is going on", function() { + it('should avoid calling addClass accidentally when removeClass is going on', function() { module('ngAnimateMock'); inject(function($compile, $rootScope, $animate, $timeout) { element = angular.element('
          '); - var body = jqLite(document.body); + var body = jqLite(window.document.body); body.append(element); $compile(element)($rootScope); @@ -414,14 +734,13 @@ describe('ngClass animations', function() { }); }); - it("should consider the ngClass expression evaluation before performing an animation", function() { + it('should combine the ngClass evaluation with the enter animation', function() { //mocks are not used since the enter delegation method is called before addClass and //it makes it impossible to test to see that addClass is called first module('ngAnimate'); module('ngAnimateMock'); - var digestQueue = []; module(function($animateProvider) { $animateProvider.register('.crazy', function() { return { @@ -431,25 +750,9 @@ describe('ngClass animations', function() { } }; }); - - return function($rootScope) { - var before = $rootScope.$$postDigest; - $rootScope.$$postDigest = function() { - var args = arguments; - digestQueue.push(function() { - before.apply($rootScope, args); - }); - }; - }; }); - inject(function($compile, $rootScope, $browser, $rootElement, $animate, $timeout, $document) { - - // Animations need to digest twice in order to be enabled regardless if there are no template HTTP requests. - $rootScope.$digest(); - digestQueue.shift()(); - - $rootScope.$digest(); - digestQueue.shift()(); + inject(function($compile, $rootScope, $browser, $rootElement, $animate, $document) { + $animate.enabled(true); $rootScope.val = 'crazy'; element = angular.element('
          '); @@ -467,29 +770,17 @@ describe('ngClass animations', function() { expect(element.hasClass('crazy')).toBe(false); expect(enterComplete).toBe(false); - expect(digestQueue.length).toBe(1); $rootScope.$digest(); - - $timeout.flush(); - - expect(element.hasClass('crazy')).toBe(true); - expect(enterComplete).toBe(false); - - digestQueue.shift()(); //enter - expect(digestQueue.length).toBe(0); - - //we don't normally need this, but since the timing between digests - //is spaced-out then it is required so that the original digestion - //is kicked into gear + $animate.flush(); $rootScope.$digest(); - $animate.triggerCallbacks(); - expect(element.data('state')).toBe('crazy-enter'); + expect(element.hasClass('crazy')).toBe(true); expect(enterComplete).toBe(true); + expect(element.data('state')).toBe('crazy-enter'); }); }); - it("should not remove classes if they're going to be added back right after", function() { + it('should not remove classes if they\'re going to be added back right after', function() { module('ngAnimateMock'); inject(function($rootScope, $compile, $animate) { diff --git a/test/ng/directive/ngControllerSpec.js b/test/ng/directive/ngControllerSpec.js index 89bd23f9c781..f78baa7fff7e 100644 --- a/test/ng/directive/ngControllerSpec.js +++ b/test/ng/directive/ngControllerSpec.js @@ -150,4 +150,5 @@ describe('ngController', function() { $httpBackend.flush(); expect(controllerScope.name).toBeUndefined(); })); + }); diff --git a/test/ng/directive/ngEventDirsSpec.js b/test/ng/directive/ngEventDirsSpec.js index 19ad69c96d92..4a533a47174f 100644 --- a/test/ng/directive/ngEventDirsSpec.js +++ b/test/ng/directive/ngEventDirsSpec.js @@ -12,15 +12,20 @@ describe('event directives', function() { describe('ngSubmit', function() { it('should get called on form submit', inject(function($rootScope, $compile) { - element = $compile('
          ' + - '' + + element = $compile( + '' + + '' + '
          ')($rootScope); $rootScope.$digest(); + // Support: Chrome 60+ + // We need to add the form to the DOM in order for `submit` events to be properly fired. + window.document.body.appendChild(element[0]); + // prevent submit within the test harness element.on('submit', function(e) { e.preventDefault(); }); - expect($rootScope.submitted).not.toBeDefined(); + expect($rootScope.submitted).toBeUndefined(); browserTrigger(element.children()[0]); expect($rootScope.submitted).toEqual(true); @@ -33,15 +38,20 @@ describe('event directives', function() { } }; - element = $compile('
          ' + - '' + + element = $compile( + '' + + '' + '
          ')($rootScope); $rootScope.$digest(); + // Support: Chrome 60+ (on Windows) + // We need to add the form to the DOM in order for `submit` events to be properly fired. + window.document.body.appendChild(element[0]); + // prevent submit within the test harness element.on('submit', function(e) { e.preventDefault(); }); - expect($rootScope.formSubmitted).not.toBeDefined(); + expect($rootScope.formSubmitted).toBeUndefined(); browserTrigger(element.children()[0]); expect($rootScope.formSubmitted).toEqual('foo'); @@ -78,7 +88,7 @@ describe('event directives', function() { it('should call the listener synchronously inside of $apply if outside of $apply', inject(function($rootScope, $compile) { element = $compile('')($rootScope); - $rootScope.focus = jasmine.createSpy('focus').andCallFake(function() { + $rootScope.focus = jasmine.createSpy('focus').and.callFake(function() { $rootScope.value = 'newValue'; }); @@ -90,23 +100,13 @@ describe('event directives', function() { }); - describe('security', function() { + describe('DOM event object', function() { it('should allow access to the $event object', inject(function($rootScope, $compile) { var scope = $rootScope.$new(); element = $compile('')(scope); element.triggerHandler('click'); expect(scope.e.target).toBe(element[0]); })); - - it('should block access to DOM nodes (e.g. exposed via $event)', inject(function($rootScope, $compile) { - var scope = $rootScope.$new(); - element = $compile('')(scope); - expect(function() { - element.triggerHandler('click'); - }).toThrowMinErr( - '$parse', 'isecdom', 'Referencing DOM nodes in Angular expressions is disallowed! ' + - 'Expression: e = $event.target'); - })); }); describe('blur', function() { @@ -139,7 +139,7 @@ describe('event directives', function() { it('should call the listener synchronously inside of $apply if outside of $apply', inject(function($rootScope, $compile) { element = $compile('')($rootScope); - $rootScope.blur = jasmine.createSpy('blur').andCallFake(function() { + $rootScope.blur = jasmine.createSpy('blur').and.callFake(function() { $rootScope.value = 'newValue'; }); @@ -148,6 +148,133 @@ describe('event directives', function() { expect($rootScope.blur).toHaveBeenCalledOnce(); expect(element.val()).toBe('newValue'); })); + }); + + + it('should call the listener synchronously if the event is triggered inside of a digest', + inject(function($rootScope, $compile) { + var watchedVal; + + element = $compile('')($rootScope); + $rootScope.$watch('value', function(newValue) { + watchedVal = newValue; + }); + $rootScope.click = jasmine.createSpy('click').and.callFake(function() { + $rootScope.value = 'newValue'; + }); + + $rootScope.$apply(function() { + element.triggerHandler('click'); + }); + + expect($rootScope.click).toHaveBeenCalledOnce(); + expect(watchedVal).toEqual('newValue'); + })); + + + it('should call the listener synchronously if the event is triggered outside of a digest', + inject(function($rootScope, $compile) { + var watchedVal; + + element = $compile('')($rootScope); + $rootScope.$watch('value', function(newValue) { + watchedVal = newValue; + }); + $rootScope.click = jasmine.createSpy('click').and.callFake(function() { + $rootScope.value = 'newValue'; + }); + + element.triggerHandler('click'); + + expect($rootScope.click).toHaveBeenCalledOnce(); + expect(watchedVal).toEqual('newValue'); + })); + + + describe('throwing errors in event handlers', function() { + + it('should not stop execution if the event is triggered outside a digest', function() { + + module(function($exceptionHandlerProvider) { + $exceptionHandlerProvider.mode('log'); + }); + + inject(function($rootScope, $compile, $exceptionHandler, $log) { + + element = $compile('')($rootScope); + expect($log.assertEmpty()); + $rootScope.click = function() { + throw new Error('listener error'); + }; + $rootScope.do = function() { + element.triggerHandler('click'); + $log.log('done'); + }; + + $rootScope.do(); + + expect($exceptionHandler.errors).toEqual([Error('listener error')]); + expect($log.log.logs).toEqual([['done']]); + $log.reset(); + }); + }); + + + it('should not stop execution if the event is triggered inside a digest', function() { + + module(function($exceptionHandlerProvider) { + $exceptionHandlerProvider.mode('log'); + }); + + inject(function($rootScope, $compile, $exceptionHandler, $log) { + + element = $compile('')($rootScope); + expect($log.assertEmpty()); + $rootScope.click = function() { + throw new Error('listener error'); + }; + + $rootScope.do = function() { + element.triggerHandler('click'); + $log.log('done'); + }; + + $rootScope.$apply(function() { + $rootScope.do(); + }); + + expect($exceptionHandler.errors).toEqual([Error('listener error')]); + expect($log.log.logs).toEqual([['done']]); + $log.reset(); + }); + }); + + + it('should not stop execution if the event is triggered in a watch expression function', function() { + + module(function($exceptionHandlerProvider) { + $exceptionHandlerProvider.mode('log'); + }); + + inject(function($rootScope, $compile, $exceptionHandler, $log) { + + element = $compile('')($rootScope); + $rootScope.click = function() { + throw new Error('listener error'); + }; + + $rootScope.$watch(function() { + element.triggerHandler('click'); + $log.log('done'); + }); + + $rootScope.$digest(); + + expect($exceptionHandler.errors).toEqual([Error('listener error'), Error('listener error')]); + expect($log.log.logs).toEqual([['done'], ['done']]); + $log.reset(); + }); + }); }); }); diff --git a/test/ng/directive/ngHrefSpec.js b/test/ng/directive/ngHrefSpec.js new file mode 100644 index 000000000000..876699636ca9 --- /dev/null +++ b/test/ng/directive/ngHrefSpec.js @@ -0,0 +1,141 @@ +'use strict'; + +describe('ngHref', function() { + var element; + + afterEach(function() { + dealoc(element); + }); + + + it('should interpolate the expression and bind to href', inject(function($compile, $rootScope) { + element = $compile('
          ')($rootScope); + $rootScope.$digest(); + expect(element.attr('href')).toEqual('some/'); + + $rootScope.$apply(function() { + $rootScope.id = 1; + }); + expect(element.attr('href')).toEqual('some/1'); + })); + + + it('should bind href and merge with other attrs', inject(function($rootScope, $compile) { + element = $compile('')($rootScope); + $rootScope.url = 'http://server/'; + $rootScope.rel = 'REL'; + $rootScope.$digest(); + expect(element.attr('href')).toEqual('http://server/'); + expect(element.attr('rel')).toEqual('REL'); + })); + + + it('should bind href even if no interpolation', inject(function($rootScope, $compile) { + element = $compile('')($rootScope); + $rootScope.$digest(); + expect(element.attr('href')).toEqual('http://server/'); + })); + + it('should not set the href if ng-href is empty', inject(function($rootScope, $compile) { + $rootScope.url = null; + element = $compile('')($rootScope); + $rootScope.$digest(); + expect(element.attr('href')).toEqual(undefined); + })); + + it('should remove the href if ng-href changes to empty', inject(function($rootScope, $compile) { + $rootScope.url = 'http://www.google.com/'; + element = $compile('')($rootScope); + $rootScope.$digest(); + + $rootScope.url = null; + $rootScope.$digest(); + expect(element.attr('href')).toEqual(undefined); + })); + + it('should sanitize interpolated url', inject(function($rootScope, $compile) { + /* eslint no-script-url: "off" */ + $rootScope.imageUrl = 'javascript:alert(1);'; + element = $compile('')($rootScope); + $rootScope.$digest(); + expect(element.attr('href')).toBe('unsafe:javascript:alert(1);'); + })); + + it('should sanitize non-interpolated url', inject(function($rootScope, $compile) { + element = $compile('')($rootScope); + $rootScope.$digest(); + expect(element.attr('href')).toBe('unsafe:javascript:alert(1);'); + })); + + + // Support: IE 9-11 only, Edge 12-17 + if (msie || /\bEdge\/1[2-7]\.[\d.]+\b/.test(window.navigator.userAgent)) { + // IE/Edge fail when setting a href to a URL containing a % that isn't a valid escape sequence + // See https://github.com/angular/angular.js/issues/13388 + it('should throw error if ng-href contains a non-escaped percent symbol', inject(function($rootScope, $compile) { + expect(function() { + element = $compile('')($rootScope); + }).toThrow(); + })); + } + + + it('should bind numbers', inject(function($rootScope, $compile) { + element = $compile('')($rootScope); + $rootScope.$digest(); + expect(element.attr('href')).toEqual('1234'); + })); + + + it('should bind and sanitize the result of a (custom) toString() function', inject(function($rootScope, $compile) { + $rootScope.value = {}; + element = $compile('')($rootScope); + $rootScope.$digest(); + expect(element.attr('href')).toEqual('[object Object]'); + + function SafeClass() {} + + SafeClass.prototype.toString = function() { + return 'custom value'; + }; + + $rootScope.value = new SafeClass(); + $rootScope.$digest(); + expect(element.attr('href')).toEqual('custom value'); + + function UnsafeClass() {} + + UnsafeClass.prototype.toString = function() { + return 'javascript:alert(1);'; + }; + + $rootScope.value = new UnsafeClass(); + $rootScope.$digest(); + expect(element.attr('href')).toEqual('unsafe:javascript:alert(1);'); + })); + + + if (isDefined(window.SVGElement)) { + describe('SVGAElement', function() { + it('should interpolate the expression and bind to xlink:href', inject(function($compile, $rootScope) { + element = $compile('')($rootScope); + var child = element.children('a'); + $rootScope.$digest(); + expect(child.attr('xlink:href')).toEqual('some/'); + + $rootScope.$apply(function() { + $rootScope.id = 1; + }); + expect(child.attr('xlink:href')).toEqual('some/1'); + })); + + + it('should bind xlink:href even if no interpolation', inject(function($rootScope, $compile) { + element = $compile('')($rootScope); + var child = element.children('a'); + $rootScope.$digest(); + expect(child.attr('xlink:href')).toEqual('http://server/'); + })); + }); + } +}); diff --git a/test/ng/directive/ngIfSpec.js b/test/ng/directive/ngIfSpec.js old mode 100755 new mode 100644 index 9ee94c95b55b..6f7e7cb355f3 --- a/test/ng/directive/ngIfSpec.js +++ b/test/ng/directive/ngIfSpec.js @@ -1,371 +1,393 @@ 'use strict'; describe('ngIf', function() { - var $scope, $compile, element, $compileProvider; - - beforeEach(module(function(_$compileProvider_) { - $compileProvider = _$compileProvider_; - })); - beforeEach(inject(function($rootScope, _$compile_) { - $scope = $rootScope.$new(); - $compile = _$compile_; - element = $compile('
          ')($scope); - })); - - afterEach(function() { - dealoc(element); - }); - function makeIf() { - forEach(arguments, function(expr) { - element.append($compile('
          Hi
          ')($scope)); - }); - $scope.$apply(); - } + describe('basic', function() { + var $scope, $compile, element, $compileProvider; - it('should immediately remove the element if condition is falsy', function() { - makeIf('false', 'undefined', 'null', 'NaN', '\'\'', '0'); - expect(element.children().length).toBe(0); - }); + beforeEach(module(function(_$compileProvider_) { + $compileProvider = _$compileProvider_; + })); + beforeEach(inject(function($rootScope, _$compile_) { + $scope = $rootScope.$new(); + $compile = _$compile_; + element = $compile('
          ')($scope); + })); - it('should leave the element if condition is true', function() { - makeIf('true'); - expect(element.children().length).toBe(1); - }); - - it('should leave the element if the condition is a non-empty string', function() { - makeIf('\'f\'', '\'0\'', '\'false\'', '\'no\'', '\'n\'', '\'[]\''); - expect(element.children().length).toBe(6); - }); + afterEach(function() { + dealoc(element); + }); - it('should leave the element if the condition is an object', function() { - makeIf('[]', '{}'); - expect(element.children().length).toBe(2); - }); + function makeIf() { + forEach(arguments, function(expr) { + element.append($compile('
          Hi
          ')($scope)); + }); + $scope.$apply(); + } - it('should not add the element twice if the condition goes from true to true', function() { - $scope.hello = 'true1'; - makeIf('hello'); - expect(element.children().length).toBe(1); - $scope.$apply('hello = "true2"'); - expect(element.children().length).toBe(1); - }); + it('should immediately remove the element if condition is falsy', function() { + makeIf('false', 'undefined', 'null', 'NaN', '\'\'', '0'); + expect(element.children().length).toBe(0); + }); - it('should not recreate the element if the condition goes from true to true', function() { - $scope.hello = 'true1'; - makeIf('hello'); - element.children().data('flag', true); - $scope.$apply('hello = "true2"'); - expect(element.children().data('flag')).toBe(true); - }); + it('should leave the element if condition is true', function() { + makeIf('true'); + expect(element.children().length).toBe(1); + }); - it('should create then remove the element if condition changes', function() { - $scope.hello = true; - makeIf('hello'); - expect(element.children().length).toBe(1); - $scope.$apply('hello = false'); - expect(element.children().length).toBe(0); - }); + it('should leave the element if the condition is a non-empty string', function() { + makeIf('\'f\'', '\'0\'', '\'false\'', '\'no\'', '\'n\'', '\'[]\''); + expect(element.children().length).toBe(6); + }); - it('should create a new scope every time the expression evaluates to true', function() { - $scope.$apply('value = true'); - element.append($compile( - '
          ' - )($scope)); - $scope.$apply(); - expect(element.children('div').length).toBe(1); - }); + it('should leave the element if the condition is an object', function() { + makeIf('[]', '{}'); + expect(element.children().length).toBe(2); + }); - it('should destroy the child scope every time the expression evaluates to false', function() { - $scope.value = true; - element.append($compile( - '
          ' - )($scope)); - $scope.$apply(); + it('should not add the element twice if the condition goes from true to true', function() { + $scope.hello = 'true1'; + makeIf('hello'); + expect(element.children().length).toBe(1); + $scope.$apply('hello = "true2"'); + expect(element.children().length).toBe(1); + }); - var childScope = element.children().scope(); - var destroyed = false; + it('should not recreate the element if the condition goes from true to true', function() { + $scope.hello = 'true1'; + makeIf('hello'); + element.children().data('flag', true); + $scope.$apply('hello = "true2"'); + expect(element.children().data('flag')).toBe(true); + }); - childScope.$on('$destroy', function() { - destroyed = true; + it('should create then remove the element if condition changes', function() { + $scope.hello = true; + makeIf('hello'); + expect(element.children().length).toBe(1); + $scope.$apply('hello = false'); + expect(element.children().length).toBe(0); }); - $scope.value = false; - $scope.$apply(); + it('should create a new scope every time the expression evaluates to true', function() { + $scope.$apply('value = true'); + element.append($compile( + '
          ' + )($scope)); + $scope.$apply(); + expect(element.children('div').length).toBe(1); + }); - expect(destroyed).toBe(true); - }); + it('should destroy the child scope every time the expression evaluates to false', function() { + $scope.value = true; + element.append($compile( + '
          ' + )($scope)); + $scope.$apply(); - it('should play nice with other elements beside it', function() { - $scope.values = [1, 2, 3, 4]; - element.append($compile( - '
          ' + - '
          ' + - '
          ' - )($scope)); - $scope.$apply(); - expect(element.children().length).toBe(9); - $scope.$apply('values.splice(0,1)'); - expect(element.children().length).toBe(6); - $scope.$apply('values.push(1)'); - expect(element.children().length).toBe(9); - }); + var childScope = element.children().scope(); + var destroyed = false; - it('should play nice with ngInclude on the same element', inject(function($templateCache) { - $templateCache.put('test.html', [200, '{{value}}', {}]); - - $scope.value = 'first'; - element.append($compile( - '
          ' - )($scope)); - $scope.$apply(); - expect(element.text()).toBe('first'); - - $scope.value = 'later'; - $scope.$apply(); - expect(element.text()).toBe(''); - })); - - it('should work with multiple elements', function() { - $scope.show = true; - $scope.things = [1, 2, 3]; - element.append($compile( - '
          before;
          ' + - '
          start;
          ' + - '
          {{thing}};
          ' + - '
          end;
          ' + - '
          after;
          ' - )($scope)); - $scope.$apply(); - expect(element.text()).toBe('before;start;1;2;3;end;after;'); - - $scope.things.push(4); - $scope.$apply(); - expect(element.text()).toBe('before;start;1;2;3;4;end;after;'); - - $scope.show = false; - $scope.$apply(); - expect(element.text()).toBe('before;after;'); - }); + childScope.$on('$destroy', function() { + destroyed = true; + }); - it('should restore the element to its compiled state', function() { - $scope.value = true; - makeIf('value'); - expect(element.children().length).toBe(1); - jqLite(element.children()[0]).removeClass('my-class'); - expect(element.children()[0].className).not.toContain('my-class'); - $scope.$apply('value = false'); - expect(element.children().length).toBe(0); - $scope.$apply('value = true'); - expect(element.children().length).toBe(1); - expect(element.children()[0].className).toContain('my-class'); - }); + $scope.value = false; + $scope.$apply(); - it('should work when combined with an ASYNC template that loads after the first digest', inject(function($httpBackend, $compile, $rootScope) { - $compileProvider.directive('test', function() { - return { - templateUrl: 'test.html' - }; + expect(destroyed).toBe(true); }); - $httpBackend.whenGET('test.html').respond('hello'); - element.append('
          '); - $compile(element)($rootScope); - $rootScope.show = true; - $rootScope.$apply(); - expect(element.text()).toBe(''); - - $httpBackend.flush(); - expect(element.text()).toBe('hello'); - - $rootScope.show = false; - $rootScope.$apply(); - // Note: there are still comments in element! - expect(element.children().length).toBe(0); - expect(element.text()).toBe(''); - })); -}); -describe('ngIf and transcludes', function() { - it('should allow access to directive controller from children when used in a replace template', function() { - var controller; - module(function($compileProvider) { - var directive = $compileProvider.directive; - directive('template', valueFn({ - template: '
          ', - replace: true, - controller: function() { - this.flag = true; - } - })); - directive('test', valueFn({ - require: '^template', - link: function(scope, el, attr, ctrl) { - controller = ctrl; - } - })); + it('should play nice with other elements beside it', function() { + $scope.values = [1, 2, 3, 4]; + element.append($compile( + '
          ' + + '
          ' + + '
          ' + )($scope)); + $scope.$apply(); + expect(element.children().length).toBe(9); + $scope.$apply('values.splice(0,1)'); + expect(element.children().length).toBe(6); + $scope.$apply('values.push(1)'); + expect(element.children().length).toBe(9); }); - inject(function($compile, $rootScope) { - var element = $compile('
          ')($rootScope); - $rootScope.$apply(); - expect(controller.flag).toBe(true); - dealoc(element); - }); - }); - - it('should use the correct transcluded scope', function() { - module(function($compileProvider) { - $compileProvider.directive('iso', valueFn({ - link: function(scope) { - scope.val = 'value in iso scope'; - }, - restrict: 'E', - transclude: true, - template: '
          val={{val}}-
          ', - scope: {} - })); - }); - inject(function($compile, $rootScope) { - $rootScope.val = 'transcluded content'; - var element = $compile('')($rootScope); - $rootScope.$digest(); - expect(trim(element.text())).toEqual('val=value in iso scope-transcluded content'); - dealoc(element); + it('should play nice with ngInclude on the same element', inject(function($templateCache) { + $templateCache.put('test.html', [200, '{{value}}', {}]); + + $scope.value = 'first'; + element.append($compile( + '
          ' + )($scope)); + $scope.$apply(); + expect(element.text()).toBe('first'); + + $scope.value = 'later'; + $scope.$apply(); + expect(element.text()).toBe(''); + })); + + it('should work with multiple elements', function() { + $scope.show = true; + $scope.things = [1, 2, 3]; + element.append($compile( + '
          before;
          ' + + '
          start;
          ' + + '
          {{thing}};
          ' + + '
          end;
          ' + + '
          after;
          ' + )($scope)); + $scope.$apply(); + expect(element.text()).toBe('before;start;1;2;3;end;after;'); + + $scope.things.push(4); + $scope.$apply(); + expect(element.text()).toBe('before;start;1;2;3;4;end;after;'); + + $scope.show = false; + $scope.$apply(); + expect(element.text()).toBe('before;after;'); }); - }); -}); -describe('ngIf animations', function() { - var body, element, $rootElement; - - function html(content) { - $rootElement.html(content); - element = $rootElement.children().eq(0); - return element; - } - - beforeEach(module('ngAnimateMock')); - - beforeEach(module(function() { - // we need to run animation on attached elements; - return function(_$rootElement_) { - $rootElement = _$rootElement_; - body = jqLite(document.body); - body.append($rootElement); - }; - })); - - afterEach(function() { - dealoc(body); - dealoc(element); - }); - - beforeEach(module(function($animateProvider, $provide) { - return function($animate) { - $animate.enabled(true); - }; - })); - - it('should fire off the enter animation', - inject(function($compile, $rootScope, $animate) { - var item; - var $scope = $rootScope.$new(); - element = $compile(html( - '
          ' + - '
          Hi
          ' + - '
          ' - ))($scope); - - $rootScope.$digest(); + it('should restore the element to its compiled state', function() { + $scope.value = true; + makeIf('value'); + expect(element.children().length).toBe(1); + jqLite(element.children()[0]).removeClass('my-class'); + expect(element.children()[0].className).not.toContain('my-class'); + $scope.$apply('value = false'); + expect(element.children().length).toBe(0); $scope.$apply('value = true'); + expect(element.children().length).toBe(1); + expect(element.children()[0].className).toContain('my-class'); + }); + + it('should work when combined with an ASYNC template that loads after the first digest', inject(function($httpBackend, $compile, $rootScope) { + $compileProvider.directive('test', function() { + return { + templateUrl: 'test.html' + }; + }); + $httpBackend.whenGET('test.html').respond('hello'); + element.append('
          '); + $compile(element)($rootScope); + $rootScope.show = true; + $rootScope.$apply(); + expect(element.text()).toBe(''); - item = $animate.queue.shift(); - expect(item.event).toBe('enter'); - expect(item.element.text()).toBe('Hi'); + $httpBackend.flush(); + expect(element.text()).toBe('hello'); - expect(element.children().length).toBe(1); - }) - ); - - it('should fire off the leave animation', - inject(function($compile, $rootScope, $animate) { - var item; - var $scope = $rootScope.$new(); - element = $compile(html( - '
          ' + - '
          Hi
          ' + - '
          ' - ))($scope); - $scope.$apply('value = true'); + $rootScope.show = false; + $rootScope.$apply(); + // Note: there are still comments in element! + expect(element.children().length).toBe(0); + expect(element.text()).toBe(''); + })); - item = $animate.queue.shift(); - expect(item.event).toBe('enter'); - expect(item.element.text()).toBe('Hi'); + it('should not trigger a digest when the element is removed', inject(function($$rAF, $rootScope, $timeout) { + var spy = spyOn($rootScope, '$digest').and.callThrough(); + $scope.hello = true; + makeIf('hello'); expect(element.children().length).toBe(1); - $scope.$apply('value = false'); + $scope.$apply('hello = false'); + spy.calls.reset(); + expect(element.children().length).toBe(0); + // The animation completion is async even without actual animations + $$rAF.flush(); - item = $animate.queue.shift(); - expect(item.event).toBe('leave'); - expect(item.element.text()).toBe('Hi'); + expect(spy).not.toHaveBeenCalled(); + // A digest may have been triggered asynchronously, so check the queue + $timeout.verifyNoPendingTasks(); + })); + }); - expect(element.children().length).toBe(0); - }) - ); - - it('should destroy the previous leave animation if a new one takes place', function() { - module(function($provide) { - $provide.decorator('$animate', function($delegate, $$q) { - var emptyPromise = $$q.defer().promise; - $delegate.leave = function() { - return emptyPromise; - }; - return $delegate; + describe('and transcludes', function() { + it('should allow access to directive controller from children when used in a replace template', function() { + var controller; + module(function($compileProvider) { + var directive = $compileProvider.directive; + directive('template', valueFn({ + template: '
          ', + replace: true, + controller: function() { + this.flag = true; + } + })); + directive('test', valueFn({ + require: '^template', + link: function(scope, el, attr, ctrl) { + controller = ctrl; + } + })); + }); + inject(function($compile, $rootScope) { + var element = $compile('
          ')($rootScope); + $rootScope.$apply(); + expect(controller.flag).toBe(true); + dealoc(element); }); }); - inject(function($compile, $rootScope, $animate) { - var item; - var $scope = $rootScope.$new(); - element = $compile(html( - '
          ' + - '
          Yo
          ' + - '
          ' - ))($scope); - $scope.$apply('value = true'); - var destroyed, inner = element.children(0); - inner.on('$destroy', function() { - destroyed = true; + it('should use the correct transcluded scope', function() { + module(function($compileProvider) { + $compileProvider.directive('iso', valueFn({ + link: function(scope) { + scope.val = 'value in iso scope'; + }, + restrict: 'E', + transclude: true, + template: '
          val={{val}}-
          ', + scope: {} + })); + }); + inject(function($compile, $rootScope) { + $rootScope.val = 'transcluded content'; + var element = $compile('')($rootScope); + $rootScope.$digest(); + expect(trim(element.text())).toEqual('val=value in iso scope-transcluded content'); + dealoc(element); }); + }); + }); - $scope.$apply('value = false'); + describe('and animations', function() { + var body, element, $rootElement; - $scope.$apply('value = true'); + function html(content) { + $rootElement.html(content); + element = $rootElement.children().eq(0); + return element; + } - $scope.$apply('value = false'); + beforeEach(module('ngAnimateMock')); - expect(destroyed).toBe(true); + beforeEach(module(function() { + // we need to run animation on attached elements; + return function(_$rootElement_) { + $rootElement = _$rootElement_; + body = jqLite(window.document.body); + body.append($rootElement); + }; + })); + + afterEach(function() { + dealoc(body); + dealoc(element); }); - }); - it('should work with svg elements when the svg container is transcluded', function() { - module(function($compileProvider) { - $compileProvider.directive('svgContainer', function() { - return { - template: '', - replace: true, - transclude: true - }; + beforeEach(module(function($animateProvider, $provide) { + return function($animate) { + $animate.enabled(true); + }; + })); + + it('should fire off the enter animation', + inject(function($compile, $rootScope, $animate) { + var item; + var $scope = $rootScope.$new(); + element = $compile(html( + '
          ' + + '
          Hi
          ' + + '
          ' + ))($scope); + + $rootScope.$digest(); + $scope.$apply('value = true'); + + item = $animate.queue.shift(); + expect(item.event).toBe('enter'); + expect(item.element.text()).toBe('Hi'); + + expect(element.children().length).toBe(1); + }) + ); + + it('should fire off the leave animation', + inject(function($compile, $rootScope, $animate) { + var item; + var $scope = $rootScope.$new(); + element = $compile(html( + '
          ' + + '
          Hi
          ' + + '
          ' + ))($scope); + $scope.$apply('value = true'); + + item = $animate.queue.shift(); + expect(item.event).toBe('enter'); + expect(item.element.text()).toBe('Hi'); + + expect(element.children().length).toBe(1); + $scope.$apply('value = false'); + + item = $animate.queue.shift(); + expect(item.event).toBe('leave'); + expect(item.element.text()).toBe('Hi'); + + expect(element.children().length).toBe(0); + }) + ); + + it('should destroy the previous leave animation if a new one takes place', function() { + module(function($provide) { + $provide.decorator('$animate', function($delegate, $$q) { + var emptyPromise = $$q.defer().promise; + emptyPromise.done = noop; + + $delegate.leave = function() { + return emptyPromise; + }; + return $delegate; + }); + }); + inject(function($compile, $rootScope, $animate) { + var item; + var $scope = $rootScope.$new(); + element = $compile(html( + '
          ' + + '
          Yo
          ' + + '
          ' + ))($scope); + + $scope.$apply('value = true'); + + var destroyed, inner = element.children(0); + inner.on('$destroy', function() { + destroyed = true; + }); + + $scope.$apply('value = false'); + + $scope.$apply('value = true'); + + $scope.$apply('value = false'); + + expect(destroyed).toBe(true); }); }); - inject(function($compile, $rootScope) { - element = $compile('')($rootScope); - $rootScope.flag = true; - $rootScope.$apply(); - var circle = element.find('circle'); - expect(circle[0].toString()).toMatch(/SVG/); + it('should work with svg elements when the svg container is transcluded', function() { + module(function($compileProvider) { + $compileProvider.directive('svgContainer', function() { + return { + template: '', + replace: true, + transclude: true + }; + }); + }); + inject(function($compile, $rootScope) { + element = $compile('')($rootScope); + $rootScope.flag = true; + $rootScope.$apply(); + + var circle = element.find('circle'); + expect(circle[0].toString()).toMatch(/SVG/); + }); }); }); }); diff --git a/test/ng/directive/ngIncludeSpec.js b/test/ng/directive/ngIncludeSpec.js index b1ec3e735e1f..e1261c2040e7 100644 --- a/test/ng/directive/ngIncludeSpec.js +++ b/test/ng/directive/ngIncludeSpec.js @@ -1,773 +1,827 @@ 'use strict'; describe('ngInclude', function() { - var element; - afterEach(function() { - dealoc(element); - }); + describe('basic', function() { + var element; + afterEach(function() { + dealoc(element); + }); - function putIntoCache(url, content) { - return function($templateCache) { - $templateCache.put(url, [200, content, {}]); - }; - } - - - it('should trust and use literal urls', inject(function( - $rootScope, $httpBackend, $compile) { - element = $compile('
          ')($rootScope); - $httpBackend.expect('GET', 'url').respond('template text'); - $rootScope.$digest(); - $httpBackend.flush(); - expect(element.text()).toEqual('template text'); - dealoc($rootScope); - })); - - - it('should trust and use trusted urls', inject(function($rootScope, $httpBackend, $compile, $sce) { - element = $compile('
          ')($rootScope); - $httpBackend.expect('GET', 'http://foo.bar/url').respond('template text'); - $rootScope.fooUrl = $sce.trustAsResourceUrl('http://foo.bar/url'); - $rootScope.$digest(); - $httpBackend.flush(); - expect(element.text()).toEqual('template text'); - dealoc($rootScope); - })); - - - it('should include an external file', inject(putIntoCache('myUrl', '{{name}}'), - function($rootScope, $compile) { - element = jqLite('
          '); - var body = jqLite(document.body); - body.append(element); - element = $compile(element)($rootScope); - $rootScope.name = 'misko'; - $rootScope.url = 'myUrl'; - $rootScope.$digest(); - expect(body.text()).toEqual('misko'); - body.empty(); - })); - - - it('should support ng-include="src" syntax', inject(putIntoCache('myUrl', '{{name}}'), - function($rootScope, $compile) { - element = jqLite('
          '); - jqLite(document.body).append(element); - element = $compile(element)($rootScope); - $rootScope.name = 'Alibaba'; - $rootScope.url = 'myUrl'; - $rootScope.$digest(); - expect(element.text()).toEqual('Alibaba'); - jqLite(document.body).empty(); - })); - - - it('should NOT use untrusted URL expressions ', inject(putIntoCache('myUrl', '{{name}} text'), - function($rootScope, $compile, $sce) { - element = jqLite(''); - jqLite(document.body).append(element); - element = $compile(element)($rootScope); - $rootScope.name = 'chirayu'; - $rootScope.url = 'http://example.com/myUrl'; - expect(function() { $rootScope.$digest(); }).toThrowMinErr( - '$sce', 'insecurl', - /Blocked loading resource from url not allowed by \$sceDelegate policy. URL: http:\/\/example.com\/myUrl.*/); - jqLite(document.body).empty(); - })); - - - it('should NOT use mistyped expressions ', inject(putIntoCache('myUrl', '{{name}} text'), - function($rootScope, $compile, $sce) { - element = jqLite(''); - jqLite(document.body).append(element); - element = $compile(element)($rootScope); - $rootScope.name = 'chirayu'; - $rootScope.url = $sce.trustAsUrl('http://example.com/myUrl'); - expect(function() { $rootScope.$digest(); }).toThrowMinErr( - '$sce', 'insecurl', - /Blocked loading resource from url not allowed by \$sceDelegate policy. URL: http:\/\/example.com\/myUrl.*/); - jqLite(document.body).empty(); - })); - - - it('should remove previously included text if a falsy value is bound to src', inject( - putIntoCache('myUrl', '{{name}}'), - function($rootScope, $compile) { - element = jqLite('
          '); - element = $compile(element)($rootScope); - $rootScope.name = 'igor'; - $rootScope.url = 'myUrl'; - $rootScope.$digest(); - expect(element.text()).toEqual('igor'); + function putIntoCache(url, content) { + return function($templateCache) { + $templateCache.put(url, [200, content, {}]); + }; + } - $rootScope.url = undefined; - $rootScope.$digest(); - expect(element.text()).toEqual(''); - })); + it('should trust and use literal urls', inject(function( + $rootScope, $httpBackend, $compile) { + element = $compile('
          ')($rootScope); + $httpBackend.expect('GET', 'url').respond('template text'); + $rootScope.$digest(); + $httpBackend.flush(); + expect(element.text()).toEqual('template text'); + dealoc($rootScope); + })); - it('should fire $includeContentRequested event on scope after making the xhr call', inject( - function($rootScope, $compile, $httpBackend) { - var contentRequestedSpy = jasmine.createSpy('content requested').andCallFake(function(event) { - expect(event.targetScope).toBe($rootScope); - }); - $httpBackend.whenGET('url').respond('my partial'); - $rootScope.$on('$includeContentRequested', contentRequestedSpy); + it('should trust and use trusted urls', inject(function($rootScope, $httpBackend, $compile, $sce) { + element = $compile('
          ')($rootScope); + $httpBackend.expect('GET', 'http://foo.bar/url').respond('template text'); + $rootScope.fooUrl = $sce.trustAsResourceUrl('http://foo.bar/url'); + $rootScope.$digest(); + $httpBackend.flush(); + expect(element.text()).toEqual('template text'); + dealoc($rootScope); + })); - element = $compile('
          ')($rootScope); - $rootScope.$digest(); - expect(contentRequestedSpy).toHaveBeenCalledOnceWith(jasmine.any(Object), 'url'); + it('should include an external file', inject(putIntoCache('myUrl', '{{name}}'), + function($rootScope, $compile) { + element = jqLite('
          '); + var body = jqLite(window.document.body); + body.append(element); + element = $compile(element)($rootScope); + $rootScope.name = 'misko'; + $rootScope.url = 'myUrl'; + $rootScope.$digest(); + expect(body.text()).toEqual('misko'); + body.empty(); + })); - $httpBackend.flush(); - })); - it('should fire $includeContentLoaded event on child scope after linking the content', inject( - function($rootScope, $compile, $templateCache) { - var contentLoadedSpy = jasmine.createSpy('content loaded').andCallFake(function(event) { - expect(event.targetScope.$parent).toBe($rootScope); - expect(element.text()).toBe('partial content'); - }); + it('should support ng-include="src" syntax', inject(putIntoCache('myUrl', '{{name}}'), + function($rootScope, $compile) { + element = jqLite('
          '); + jqLite(window.document.body).append(element); + element = $compile(element)($rootScope); + $rootScope.name = 'Alibaba'; + $rootScope.url = 'myUrl'; + $rootScope.$digest(); + expect(element.text()).toEqual('Alibaba'); + jqLite(window.document.body).empty(); + })); - $templateCache.put('url', [200, 'partial content', {}]); - $rootScope.$on('$includeContentLoaded', contentLoadedSpy); - element = $compile('
          ')($rootScope); - $rootScope.$digest(); + it('should NOT use untrusted URL expressions ', inject(putIntoCache('myUrl', '{{name}} text'), + function($rootScope, $compile, $sce) { + element = jqLite(''); + jqLite(window.document.body).append(element); + element = $compile(element)($rootScope); + $rootScope.name = 'chirayu'; + $rootScope.url = 'http://example.com/myUrl'; + expect(function() { $rootScope.$digest(); }).toThrowMinErr( + '$sce', 'insecurl', + /Blocked loading resource from url not allowed by \$sceDelegate policy. {2}URL: http:\/\/example.com\/myUrl.*/); + jqLite(window.document.body).empty(); + })); - expect(contentLoadedSpy).toHaveBeenCalledOnceWith(jasmine.any(Object), 'url'); - })); + it('should NOT use mistyped expressions ', inject(putIntoCache('myUrl', '{{name}} text'), + function($rootScope, $compile, $sce) { + element = jqLite(''); + jqLite(window.document.body).append(element); + element = $compile(element)($rootScope); + $rootScope.name = 'chirayu'; + $rootScope.url = $sce.trustAsUrl('http://example.com/myUrl'); + expect(function() { $rootScope.$digest(); }).toThrowMinErr( + '$sce', 'insecurl', + /Blocked loading resource from url not allowed by \$sceDelegate policy. {2}URL: http:\/\/example.com\/myUrl.*/); + jqLite(window.document.body).empty(); + })); - it('should fire $includeContentError event when content request fails', inject( - function($rootScope, $compile, $httpBackend, $templateCache) { - var contentLoadedSpy = jasmine.createSpy('content loaded'), - contentErrorSpy = jasmine.createSpy('content error'); - $rootScope.$on('$includeContentLoaded', contentLoadedSpy); - $rootScope.$on('$includeContentError', contentErrorSpy); + it('should remove previously included text if a falsy value is bound to src', inject( + putIntoCache('myUrl', '{{name}}'), + function($rootScope, $compile) { + element = jqLite('
          '); + element = $compile(element)($rootScope); + $rootScope.name = 'igor'; + $rootScope.url = 'myUrl'; + $rootScope.$digest(); - $httpBackend.expect('GET', 'tpl.html').respond(400, 'nope'); + expect(element.text()).toEqual('igor'); - element = $compile('
          ')($rootScope); + $rootScope.url = undefined; + $rootScope.$digest(); - $rootScope.$apply(function() { - $rootScope.template = 'tpl.html'; - }); - $httpBackend.flush(); + expect(element.text()).toEqual(''); + })); - expect(contentLoadedSpy).not.toHaveBeenCalled(); - expect(contentErrorSpy).toHaveBeenCalledOnceWith(jasmine.any(Object), 'tpl.html'); - expect(element.children('div').contents().length).toBe(0); - })); + it('should fire $includeContentRequested event on scope after making the xhr call', inject( + function($rootScope, $compile, $httpBackend) { + var contentRequestedSpy = jasmine.createSpy('content requested').and.callFake(function(event) { + expect(event.targetScope).toBe($rootScope); + }); + $httpBackend.whenGET('url').respond('my partial'); + $rootScope.$on('$includeContentRequested', contentRequestedSpy); - it('should evaluate onload expression when a partial is loaded', inject( - putIntoCache('myUrl', 'my partial'), - function($rootScope, $compile) { - element = jqLite('
          '); - element = $compile(element)($rootScope); + element = $compile('
          ')($rootScope); + $rootScope.$digest(); - expect($rootScope.loaded).not.toBeDefined(); + expect(contentRequestedSpy).toHaveBeenCalledOnceWith(jasmine.any(Object), 'url'); - $rootScope.url = 'myUrl'; - $rootScope.$digest(); + $httpBackend.flush(); + })); - expect(element.text()).toEqual('my partial'); - expect($rootScope.loaded).toBe(true); - })); + it('should fire $includeContentLoaded event on child scope after linking the content', inject( + function($rootScope, $compile, $templateCache) { + var contentLoadedSpy = jasmine.createSpy('content loaded').and.callFake(function(event) { + expect(event.targetScope.$parent).toBe($rootScope); + expect(element.text()).toBe('partial content'); + }); + $templateCache.put('url', [200, 'partial content', {}]); + $rootScope.$on('$includeContentLoaded', contentLoadedSpy); - it('should create child scope and destroy old one', inject( - function($rootScope, $compile, $httpBackend) { - $httpBackend.whenGET('url1').respond('partial {{$parent.url}}'); - $httpBackend.whenGET('url2').respond(404); + element = $compile('
          ')($rootScope); + $rootScope.$digest(); - element = $compile('
          ')($rootScope); - expect(element.children().scope()).toBeFalsy(); + expect(contentLoadedSpy).toHaveBeenCalledOnceWith(jasmine.any(Object), 'url'); + })); - $rootScope.url = 'url1'; - $rootScope.$digest(); - $httpBackend.flush(); - expect(element.children().scope().$parent).toBe($rootScope); - expect(element.text()).toBe('partial url1'); - $rootScope.url = 'url2'; - $rootScope.$digest(); - $httpBackend.flush(); + it('should fire $includeContentError event when content request fails', inject( + function($rootScope, $compile, $httpBackend, $templateCache) { + var contentLoadedSpy = jasmine.createSpy('content loaded'), + contentErrorSpy = jasmine.createSpy('content error'); - expect($rootScope.$$childHead).toBeFalsy(); - expect(element.text()).toBe(''); + $rootScope.$on('$includeContentLoaded', contentLoadedSpy); + $rootScope.$on('$includeContentError', contentErrorSpy); - $rootScope.url = 'url1'; - $rootScope.$digest(); - expect(element.children().scope().$parent).toBe($rootScope); + $httpBackend.expect('GET', 'tpl.html').respond(400, 'nope'); - $rootScope.url = null; - $rootScope.$digest(); - expect($rootScope.$$childHead).toBeFalsy(); - })); + element = $compile('
          ')($rootScope); + $rootScope.$apply(function() { + $rootScope.template = 'tpl.html'; + }); + $httpBackend.flush(); - it('should do xhr request and cache it', - inject(function($rootScope, $httpBackend, $compile) { - element = $compile('
          ')($rootScope); - $httpBackend.expect('GET', 'myUrl').respond('my partial'); + expect(contentLoadedSpy).not.toHaveBeenCalled(); + expect(contentErrorSpy).toHaveBeenCalledOnceWith(jasmine.any(Object), 'tpl.html'); + expect(element.children('div').contents().length).toBe(0); + })); - $rootScope.url = 'myUrl'; - $rootScope.$digest(); - $httpBackend.flush(); - expect(element.text()).toEqual('my partial'); - $rootScope.url = null; - $rootScope.$digest(); - expect(element.text()).toEqual(''); + it('should evaluate onload expression when a partial is loaded', inject( + putIntoCache('myUrl', 'my partial'), + function($rootScope, $compile) { + element = jqLite('
          '); + element = $compile(element)($rootScope); - $rootScope.url = 'myUrl'; - $rootScope.$digest(); - expect(element.text()).toEqual('my partial'); - dealoc($rootScope); - })); + expect($rootScope.loaded).not.toBeDefined(); + $rootScope.url = 'myUrl'; + $rootScope.$digest(); - it('should clear content when error during xhr request', - inject(function($httpBackend, $compile, $rootScope) { - element = $compile('
          content
          ')($rootScope); - $httpBackend.expect('GET', 'myUrl').respond(404, ''); + expect(element.text()).toEqual('my partial'); + expect($rootScope.loaded).toBe(true); + })); - $rootScope.url = 'myUrl'; - $rootScope.$digest(); - $httpBackend.flush(); - expect(element.text()).toBe(''); - })); + it('should create child scope and destroy old one', inject( + function($rootScope, $compile, $httpBackend) { + $httpBackend.whenGET('url1').respond('partial {{$parent.url}}'); + $httpBackend.whenGET('url2').respond(404); + element = $compile('
          ')($rootScope); + expect(element.children().scope()).toBeFalsy(); - it('should be async even if served from cache', inject( - putIntoCache('myUrl', 'my partial'), - function($rootScope, $compile) { - element = $compile('
          ')($rootScope); + $rootScope.url = 'url1'; + $rootScope.$digest(); + $httpBackend.flush(); + expect(element.children().scope().$parent).toBe($rootScope); + expect(element.text()).toBe('partial url1'); - $rootScope.url = 'myUrl'; + $rootScope.url = 'url2'; + $rootScope.$digest(); + $httpBackend.flush(); - var called = 0; - // we want to assert only during first watch - $rootScope.$watch(function() { - if (!called) expect(element.text()).toBe(''); - called++; - }); + expect($rootScope.$$childHead).toBeFalsy(); + expect(element.text()).toBe(''); - $rootScope.$digest(); - expect(element.text()).toBe('my partial'); - })); + $rootScope.url = 'url1'; + $rootScope.$digest(); + expect(element.children().scope().$parent).toBe($rootScope); + $rootScope.url = null; + $rootScope.$digest(); + expect($rootScope.$$childHead).toBeFalsy(); + })); - it('should discard pending xhr callbacks if a new template is requested before the current ' + - 'finished loading', inject(function($rootScope, $compile, $httpBackend) { - element = jqLite("
          "); - var log = {}; - $rootScope.templateUrl = 'myUrl1'; - $rootScope.logger = function(msg) { - log[msg] = true; - }; - $compile(element)($rootScope); - expect(log).toEqual({}); + it('should do xhr request and cache it', + inject(function($rootScope, $httpBackend, $compile) { + element = $compile('
          ')($rootScope); + $httpBackend.expect('GET', 'myUrl').respond('my partial'); - $httpBackend.expect('GET', 'myUrl1').respond('
          {{logger("url1")}}
          '); - $rootScope.$digest(); - expect(log).toEqual({}); - $rootScope.templateUrl = 'myUrl2'; - $httpBackend.expect('GET', 'myUrl2').respond('
          {{logger("url2")}}
          '); - $httpBackend.flush(); // now that we have two requests pending, flush! + $rootScope.url = 'myUrl'; + $rootScope.$digest(); + $httpBackend.flush(); + expect(element.text()).toEqual('my partial'); - expect(log).toEqual({ url2: true }); - })); + $rootScope.url = null; + $rootScope.$digest(); + expect(element.text()).toEqual(''); + $rootScope.url = 'myUrl'; + $rootScope.$digest(); + expect(element.text()).toEqual('my partial'); + dealoc($rootScope); + })); - it('should compile only the content', inject(function($compile, $rootScope, $templateCache) { - // regression - var onload = jasmine.createSpy('$includeContentLoaded'); - $rootScope.$on('$includeContentLoaded', onload); - $templateCache.put('tpl.html', [200, 'partial {{tpl}}', {}]); + it('should clear content when error during xhr request', + inject(function($httpBackend, $compile, $rootScope) { + element = $compile('
          content
          ')($rootScope); + $httpBackend.expect('GET', 'myUrl').respond(404, ''); - element = $compile('
          ' + - '
          ')($rootScope); - expect(onload).not.toHaveBeenCalled(); + $rootScope.url = 'myUrl'; + $rootScope.$digest(); + $httpBackend.flush(); - $rootScope.$apply(function() { - $rootScope.tpl = 'tpl.html'; - }); - expect(onload).toHaveBeenCalledOnce(); - - $rootScope.tpl = ''; - $rootScope.$digest(); - dealoc(element); - })); - - - it('should not break attribute bindings on the same element', inject(function($compile, $rootScope, $httpBackend) { - // regression #3793 - - element = $compile('
          ')($rootScope); - $httpBackend.expect('GET', 'url1').respond('template text 1'); - $rootScope.hrefUrl = 'fooUrl1'; - $rootScope.includeUrl = 'url1'; - $rootScope.$digest(); - $httpBackend.flush(); - expect(element.text()).toBe('template text 1'); - expect(element.find('span').attr('foo')).toBe('#/fooUrl1'); - - $httpBackend.expect('GET', 'url2').respond('template text 2'); - $rootScope.includeUrl = 'url2'; - $rootScope.$digest(); - $httpBackend.flush(); - expect(element.text()).toBe('template text 2'); - expect(element.find('span').attr('foo')).toBe('#/fooUrl1'); - - $rootScope.hrefUrl = 'fooUrl2'; - $rootScope.$digest(); - expect(element.text()).toBe('template text 2'); - expect(element.find('span').attr('foo')).toBe('#/fooUrl2'); - })); - - - it('should exec scripts when jQuery is included', inject(function($compile, $rootScope, $httpBackend) { - if (!jQuery) { - return; - } + expect(element.text()).toBe(''); + })); - element = $compile('
          ')($rootScope); - // the element needs to be appended for the script to run - element.appendTo(document.body); - window._ngIncludeCausesScriptToRun = false; - $httpBackend.expect('GET', 'url1').respond(''); - $rootScope.includeUrl = 'url1'; - $rootScope.$digest(); - $httpBackend.flush(); + it('should be async even if served from cache', inject( + putIntoCache('myUrl', 'my partial'), + function($rootScope, $compile) { + element = $compile('
          ')($rootScope); - expect(window._ngIncludeCausesScriptToRun).toBe(true); + $rootScope.url = 'myUrl'; - // IE8 doesn't like deleting properties of window - window._ngIncludeCausesScriptToRun = undefined; - try { - delete window._ngIncludeCausesScriptToRun; - } catch (e) {} - })); + var called = 0; + // we want to assert only during first watch + $rootScope.$watch(function() { + if (!called) expect(element.text()).toBe(''); + called++; + }); + $rootScope.$digest(); + expect(element.text()).toBe('my partial'); + })); - it('should construct SVG template elements with correct namespace', function() { - if (!window.SVGRectElement) return; - module(function($compileProvider) { - $compileProvider.directive('test', valueFn({ - templateNamespace: 'svg', - templateUrl: 'my-rect.html', - replace: true - })); - }); - inject(function($compile, $rootScope, $httpBackend) { - $httpBackend.expectGET('my-rect.html').respond(''); - $httpBackend.expectGET('include.svg').respond(''); - element = $compile('')($rootScope); - $httpBackend.flush(); - var child = element.find('rect'); - expect(child.length).toBe(2); - expect(child[0] instanceof SVGRectElement).toBe(true); - }); - }); + it('should discard pending xhr callbacks if a new template is requested before the current ' + + 'finished loading', inject(function($rootScope, $compile, $httpBackend) { + element = jqLite('
          '); + var log = {}; - it('should compile only the template content of an SVG template', function() { - if (!window.SVGRectElement) return; - module(function($compileProvider) { - $compileProvider.directive('test', valueFn({ - templateNamespace: 'svg', - templateUrl: 'my-rect.html', - replace: true - })); - }); - inject(function($compile, $rootScope, $httpBackend) { - $httpBackend.expectGET('my-rect.html').respond(''); - $httpBackend.expectGET('include.svg').respond(''); - element = $compile('')($rootScope); - $httpBackend.flush(); - expect(element.find('a').length).toBe(0); - }); - }); + $rootScope.templateUrl = 'myUrl1'; + $rootScope.logger = function(msg) { + log[msg] = true; + }; + $compile(element)($rootScope); + expect(log).toEqual({}); + $httpBackend.expect('GET', 'myUrl1').respond('
          {{logger("url1")}}
          '); + $rootScope.$digest(); + expect(log).toEqual({}); + $rootScope.templateUrl = 'myUrl2'; + $httpBackend.expect('GET', 'myUrl2').respond('
          {{logger("url2")}}
          '); + $httpBackend.flush(); // now that we have two requests pending, flush! - describe('autoscroll', function() { - var autoScrollSpy; + expect(log).toEqual({ url2: true }); + })); - function spyOnAnchorScroll() { - return function($provide) { - autoScrollSpy = jasmine.createSpy('$anchorScroll'); - $provide.value('$anchorScroll', autoScrollSpy); - }; - } - function compileAndLink(tpl) { - return function($compile, $rootScope) { - element = $compile(tpl)($rootScope); - }; - } + it('should compile only the content', inject(function($compile, $rootScope, $templateCache) { + // regression - beforeEach(module(spyOnAnchorScroll(), 'ngAnimateMock')); - beforeEach(inject( - putIntoCache('template.html', 'CONTENT'), - putIntoCache('another.html', 'CONTENT'))); + var onload = jasmine.createSpy('$includeContentLoaded'); + $rootScope.$on('$includeContentLoaded', onload); + $templateCache.put('tpl.html', [200, 'partial {{tpl}}', {}]); - it('should call $anchorScroll if autoscroll attribute is present', inject( - compileAndLink('
          '), - function($rootScope, $animate, $timeout) { + element = $compile('
          ' + + '
          ')($rootScope); + expect(onload).not.toHaveBeenCalled(); $rootScope.$apply(function() { - $rootScope.tpl = 'template.html'; + $rootScope.tpl = 'tpl.html'; }); + expect(onload).toHaveBeenCalledOnce(); + + $rootScope.tpl = ''; + $rootScope.$digest(); + dealoc(element); + })); + + + it('should not break attribute bindings on the same element', inject(function($compile, $rootScope, $httpBackend) { + // regression #3793 + + element = $compile('
          ')($rootScope); + $httpBackend.expect('GET', 'url1').respond('template text 1'); + $rootScope.hrefUrl = 'fooUrl1'; + $rootScope.includeUrl = 'url1'; + $rootScope.$digest(); + $httpBackend.flush(); + expect(element.text()).toBe('template text 1'); + expect(element.find('span').attr('foo')).toBe('#/fooUrl1'); - expect(autoScrollSpy).not.toHaveBeenCalled(); - expect($animate.queue.shift().event).toBe('enter'); - $animate.triggerCallbacks(); + $httpBackend.expect('GET', 'url2').respond('template text 2'); + $rootScope.includeUrl = 'url2'; + $rootScope.$digest(); + $httpBackend.flush(); + expect(element.text()).toBe('template text 2'); + expect(element.find('span').attr('foo')).toBe('#/fooUrl1'); - expect(autoScrollSpy).toHaveBeenCalledOnce(); + $rootScope.hrefUrl = 'fooUrl2'; + $rootScope.$digest(); + expect(element.text()).toBe('template text 2'); + expect(element.find('span').attr('foo')).toBe('#/fooUrl2'); })); - it('should call $anchorScroll if autoscroll evaluates to true', - inject(function($rootScope, $compile, $animate, $timeout) { + it('should exec scripts when jQuery is included', inject(function($compile, $rootScope, $httpBackend) { + if (!jQuery) { + return; + } - element = $compile('
          ')($rootScope); + element = $compile('
          ')($rootScope); - $rootScope.$apply(function() { - $rootScope.tpl = 'template.html'; - $rootScope.value = true; + // the element needs to be appended for the script to run + element.appendTo(window.document.body); + window._ngIncludeCausesScriptToRun = false; + $httpBackend.expect('GET', 'url1').respond(''); + $rootScope.includeUrl = 'url1'; + $rootScope.$digest(); + $httpBackend.flush(); + + expect(window._ngIncludeCausesScriptToRun).toBe(true); + + delete window._ngIncludeCausesScriptToRun; + })); + + + it('should construct SVG template elements with correct namespace', function() { + if (!window.SVGRectElement) return; + module(function($compileProvider) { + $compileProvider.directive('test', valueFn({ + templateNamespace: 'svg', + templateUrl: 'my-rect.html', + replace: true + })); + }); + inject(function($compile, $rootScope, $httpBackend) { + $httpBackend.expectGET('my-rect.html').respond(''); + $httpBackend.expectGET('include.svg').respond(''); + element = $compile('')($rootScope); + $httpBackend.flush(); + var child = element.find('rect'); + expect(child.length).toBe(2); + // eslint-disable-next-line no-undef + expect(child[0] instanceof SVGRectElement).toBe(true); }); + }); - expect($animate.queue.shift().event).toBe('enter'); - $animate.triggerCallbacks(); - $rootScope.$apply(function() { - $rootScope.tpl = 'another.html'; - $rootScope.value = 'some-string'; + it('should compile only the template content of an SVG template', function() { + if (!window.SVGRectElement) return; + module(function($compileProvider) { + $compileProvider.directive('test', valueFn({ + templateNamespace: 'svg', + templateUrl: 'my-rect.html', + replace: true + })); }); + inject(function($compile, $rootScope, $httpBackend) { + $httpBackend.expectGET('my-rect.html').respond(''); + $httpBackend.expectGET('include.svg').respond(''); + element = $compile('')($rootScope); + $httpBackend.flush(); + expect(element.find('a').length).toBe(0); + }); + }); - expect($animate.queue.shift().event).toBe('leave'); - expect($animate.queue.shift().event).toBe('enter'); - $animate.triggerCallbacks(); - $rootScope.$apply(function() { - $rootScope.tpl = 'template.html'; - $rootScope.value = 100; + it('should not compile template if original scope is destroyed', function() { + module(function($provide) { + $provide.decorator('$compile', function($delegate) { + var result = jasmine.createSpy('$compile').and.callFake($delegate); + result.$$createComment = $delegate.$$createComment; + return result; + }); }); + inject(function($rootScope, $httpBackend, $compile) { + $httpBackend.when('GET', 'url').respond('template text'); + $rootScope.show = true; + element = $compile('
          ')($rootScope); + $rootScope.$digest(); + $rootScope.show = false; + $rootScope.$digest(); + $compile.calls.reset(); + $httpBackend.flush(); + expect($compile).not.toHaveBeenCalled(); + }); + }); - expect($animate.queue.shift().event).toBe('leave'); - expect($animate.queue.shift().event).toBe('enter'); - $animate.triggerCallbacks(); - expect(autoScrollSpy).toHaveBeenCalled(); - expect(autoScrollSpy.callCount).toBe(3); - })); + it('should not trigger a digest when the include is changed', function() { + inject(function($$rAF, $templateCache, $rootScope, $compile, $timeout) { + var spy = spyOn($rootScope, '$digest').and.callThrough(); - it('should not call $anchorScroll if autoscroll attribute is not present', inject( - compileAndLink('
          '), - function($rootScope, $animate, $timeout) { + $templateCache.put('myUrl', 'my template content'); + $templateCache.put('myOtherUrl', 'my other template content'); - $rootScope.$apply(function() { - $rootScope.tpl = 'template.html'; + $rootScope.url = 'myUrl'; + element = jqLite('
          '); + element = $compile(element)($rootScope); + $rootScope.$digest(); + // The animation completion is async even without actual animations + $$rAF.flush(); + expect(element.text()).toEqual('my template content'); + + $rootScope.$apply('url = "myOtherUrl"'); + spy.calls.reset(); + expect(element.text()).toEqual('my other template content'); + $$rAF.flush(); + + expect(spy).not.toHaveBeenCalled(); + // A digest may have been triggered asynchronously, so check the queue + $timeout.verifyNoPendingTasks(); }); + }); - expect($animate.queue.shift().event).toBe('enter'); - $animate.triggerCallbacks(); - expect(autoScrollSpy).not.toHaveBeenCalled(); - })); + describe('autoscroll', function() { + var autoScrollSpy; - it('should not call $anchorScroll if autoscroll evaluates to false', - inject(function($rootScope, $compile, $animate, $timeout) { + function spyOnAnchorScroll() { + return function($provide) { + autoScrollSpy = jasmine.createSpy('$anchorScroll'); + $provide.value('$anchorScroll', autoScrollSpy); + }; + } - element = $compile('
          ')($rootScope); + function compileAndLink(tpl) { + return function($compile, $rootScope) { + element = $compile(tpl)($rootScope); + }; + } - $rootScope.$apply(function() { - $rootScope.tpl = 'template.html'; - $rootScope.value = false; - }); + beforeEach(module(spyOnAnchorScroll(), 'ngAnimateMock')); + beforeEach(inject( + putIntoCache('template.html', 'CONTENT'), + putIntoCache('another.html', 'CONTENT'))); - expect($animate.queue.shift().event).toBe('enter'); - $animate.triggerCallbacks(); + it('should call $anchorScroll if autoscroll attribute is present', inject( + compileAndLink('
          '), + function($rootScope, $animate, $timeout) { - $rootScope.$apply(function() { - $rootScope.tpl = 'template.html'; - $rootScope.value = undefined; - }); + $rootScope.$apply(function() { + $rootScope.tpl = 'template.html'; + }); - $rootScope.$apply(function() { - $rootScope.tpl = 'template.html'; - $rootScope.value = null; - }); + expect(autoScrollSpy).not.toHaveBeenCalled(); - expect(autoScrollSpy).not.toHaveBeenCalled(); - })); + $animate.flush(); + $rootScope.$digest(); - it('should only call $anchorScroll after the "enter" animation completes', inject( - compileAndLink('
          '), - function($rootScope, $animate, $timeout) { - expect(autoScrollSpy).not.toHaveBeenCalled(); + expect($animate.queue.shift().event).toBe('enter'); + expect(autoScrollSpy).toHaveBeenCalledOnce(); + })); - $rootScope.$apply("tpl = 'template.html'"); - expect($animate.queue.shift().event).toBe('enter'); - $animate.triggerCallbacks(); - expect(autoScrollSpy).toHaveBeenCalledOnce(); - } - )); - }); -}); + it('should call $anchorScroll if autoscroll evaluates to true', + inject(function($rootScope, $compile, $animate, $timeout) { -describe('ngInclude and transcludes', function() { - var element, directive; + element = $compile('
          ')($rootScope); - beforeEach(module(function($compileProvider) { - element = null; - directive = $compileProvider.directive; - })); + $rootScope.$apply(function() { + $rootScope.tpl = 'template.html'; + $rootScope.value = true; + }); - afterEach(function() { - if (element) { - dealoc(element); - } - }); + expect($animate.queue.shift().event).toBe('enter'); + + $rootScope.$apply(function() { + $rootScope.tpl = 'another.html'; + $rootScope.value = 'some-string'; + }); + + expect($animate.queue.shift().event).toBe('leave'); + expect($animate.queue.shift().event).toBe('enter'); + + $rootScope.$apply(function() { + $rootScope.tpl = 'template.html'; + $rootScope.value = 100; + }); + + expect($animate.queue.shift().event).toBe('leave'); + expect($animate.queue.shift().event).toBe('enter'); + + $animate.flush(); + $rootScope.$digest(); + + expect(autoScrollSpy).toHaveBeenCalled(); + expect(autoScrollSpy).toHaveBeenCalledTimes(3); + })); + + + it('should not call $anchorScroll if autoscroll attribute is not present', inject( + compileAndLink('
          '), + function($rootScope, $animate, $timeout) { - it('should allow access to directive controller from children when used in a replace template', function() { - var controller; - module(function() { - directive('template', valueFn({ - template: '
          ', - replace: true, - controller: function() { - this.flag = true; - } + $rootScope.$apply(function() { + $rootScope.tpl = 'template.html'; + }); + + expect($animate.queue.shift().event).toBe('enter'); + expect(autoScrollSpy).not.toHaveBeenCalled(); })); - directive('test', valueFn({ - require: '^template', - link: function(scope, el, attr, ctrl) { - controller = ctrl; - } + + + it('should not call $anchorScroll if autoscroll evaluates to false', + inject(function($rootScope, $compile, $animate, $timeout) { + + element = $compile('
          ')($rootScope); + + $rootScope.$apply(function() { + $rootScope.tpl = 'template.html'; + $rootScope.value = false; + }); + + expect($animate.queue.shift().event).toBe('enter'); + + $rootScope.$apply(function() { + $rootScope.tpl = 'template.html'; + $rootScope.value = undefined; + }); + + $rootScope.$apply(function() { + $rootScope.tpl = 'template.html'; + $rootScope.value = null; + }); + + expect(autoScrollSpy).not.toHaveBeenCalled(); })); - }); - inject(function($compile, $rootScope, $httpBackend) { - $httpBackend.expectGET('include.html').respond('
          '); - element = $compile('
          ')($rootScope); - $rootScope.$apply(); - $httpBackend.flush(); - expect(controller.flag).toBe(true); - }); - }); - it("should compile its content correctly (although we remove it later)", function() { - var testElement; - module(function() { - directive('test', function() { - return { - link: function(scope, element) { - testElement = element; + it('should only call $anchorScroll after the "enter" animation completes', inject( + compileAndLink('
          '), + function($rootScope, $animate, $timeout) { + expect(autoScrollSpy).not.toHaveBeenCalled(); + + $rootScope.$apply('tpl = \'template.html\''); + expect($animate.queue.shift().event).toBe('enter'); + + $animate.flush(); + $rootScope.$digest(); + + expect(autoScrollSpy).toHaveBeenCalledOnce(); } - }; - }); - }); - inject(function($compile, $rootScope, $httpBackend) { - $httpBackend.expectGET('include.html').respond(' '); - element = $compile('
          ')($rootScope); - $rootScope.$apply(); - $httpBackend.flush(); - expect(testElement[0].nodeName).toBe('DIV'); + )); }); - }); - it('should link directives on the same element after the content has been loaded', function() { - var contentOnLink; - module(function() { - directive('test', function() { - return { - link: function(scope, element) { - contentOnLink = element.text(); + describe('and transcludes', function() { + var element, directive; + + beforeEach(module(function($compileProvider) { + element = null; + directive = $compileProvider.directive; + })); + + afterEach(function() { + if (element) { + dealoc(element); + } + }); + + it('should allow access to directive controller from children when used in a replace template', function() { + var controller; + module(function() { + directive('template', valueFn({ + template: '
          ', + replace: true, + controller: function() { + this.flag = true; } - }; + })); + directive('test', valueFn({ + require: '^template', + link: function(scope, el, attr, ctrl) { + controller = ctrl; + } + })); + }); + inject(function($compile, $rootScope, $httpBackend) { + $httpBackend.expectGET('include.html').respond('
          '); + element = $compile('
          ')($rootScope); + $rootScope.$apply(); + $httpBackend.flush(); + expect(controller.flag).toBe(true); }); }); - inject(function($compile, $rootScope, $httpBackend) { - $httpBackend.expectGET('include.html').respond('someContent'); - element = $compile('
          ')($rootScope); - $rootScope.$apply(); - $httpBackend.flush(); - expect(contentOnLink).toBe('someContent'); - }); - }); - it('should add the content to the element before compiling it', function() { - var root; - module(function() { - directive('test', function() { - return { - link: function(scope, element) { - root = element.parent().parent(); - } - }; + it('should compile its content correctly (although we remove it later)', function() { + var testElement; + module(function() { + directive('test', function() { + return { + link: function(scope, element) { + testElement = element; + } + }; + }); + }); + inject(function($compile, $rootScope, $httpBackend) { + $httpBackend.expectGET('include.html').respond(' '); + element = $compile('
          ')($rootScope); + $rootScope.$apply(); + $httpBackend.flush(); + expect(testElement[0].nodeName).toBe('DIV'); }); + }); - inject(function($compile, $rootScope, $httpBackend) { - $httpBackend.expectGET('include.html').respond(''); - element = $compile('
          ')($rootScope); - $rootScope.$apply(); - $httpBackend.flush(); - expect(root[0]).toBe(element[0]); + + it('should link directives on the same element after the content has been loaded', function() { + var contentOnLink; + module(function() { + directive('test', function() { + return { + link: function(scope, element) { + contentOnLink = element.text(); + } + }; + }); + }); + inject(function($compile, $rootScope, $httpBackend) { + $httpBackend.expectGET('include.html').respond('someContent'); + element = $compile('
          ')($rootScope); + $rootScope.$apply(); + $httpBackend.flush(); + expect(contentOnLink).toBe('someContent'); + }); }); - }); -}); -describe('ngInclude animations', function() { - var body, element, $rootElement; - - function html(content) { - $rootElement.html(content); - element = $rootElement.children().eq(0); - return element; - } - - beforeEach(module(function() { - // we need to run animation on attached elements; - return function(_$rootElement_) { - $rootElement = _$rootElement_; - body = jqLite(document.body); - body.append($rootElement); - }; - })); - - afterEach(function() { - dealoc(body); - dealoc(element); + it('should add the content to the element before compiling it', function() { + var root; + module(function() { + directive('test', function() { + return { + link: function(scope, element) { + root = element.parent().parent(); + } + }; + }); + }); + inject(function($compile, $rootScope, $httpBackend) { + $httpBackend.expectGET('include.html').respond(''); + element = $compile('
          ')($rootScope); + $rootScope.$apply(); + $httpBackend.flush(); + expect(root[0]).toBe(element[0]); + }); + }); }); - beforeEach(module('ngAnimateMock')); + describe('and animations', function() { + var body, element, $rootElement; - afterEach(function() { - dealoc(element); - }); + function html(content) { + $rootElement.html(content); + element = $rootElement.children().eq(0); + return element; + } - it('should fire off the enter animation', - inject(function($compile, $rootScope, $templateCache, $animate) { - var item; - - $templateCache.put('enter', [200, '
          data
          ', {}]); - $rootScope.tpl = 'enter'; - element = $compile(html( - '
          ' + - '
          ' - ))($rootScope); - $rootScope.$digest(); + beforeEach(module(function() { + // we need to run animation on attached elements; + return function(_$rootElement_) { + $rootElement = _$rootElement_; + body = jqLite(window.document.body); + body.append($rootElement); + }; + })); - var animation = $animate.queue.pop(); - expect(animation.event).toBe('enter'); - expect(animation.element.text()).toBe('data'); - }) - ); - - it('should fire off the leave animation', - inject(function($compile, $rootScope, $templateCache, $animate) { - var item; - $templateCache.put('enter', [200, '
          data
          ', {}]); - $rootScope.tpl = 'enter'; - element = $compile(html( - '
          ' + - '
          ' - ))($rootScope); - $rootScope.$digest(); + afterEach(function() { + dealoc(body); + dealoc(element); + }); - var animation = $animate.queue.shift(); - expect(animation.event).toBe('enter'); - expect(animation.element.text()).toBe('data'); + beforeEach(module('ngAnimateMock')); - $rootScope.tpl = ''; - $rootScope.$digest(); + afterEach(function() { + dealoc(element); + }); - animation = $animate.queue.shift(); - expect(animation.event).toBe('leave'); - expect(animation.element.text()).toBe('data'); - }) - ); - - it('should animate two separate ngInclude elements', - inject(function($compile, $rootScope, $templateCache, $animate) { - var item; - $templateCache.put('one', [200, 'one', {}]); - $templateCache.put('two', [200, 'two', {}]); - $rootScope.tpl = 'one'; - element = $compile(html( - '
          ' + - '
          ' - ))($rootScope); - $rootScope.$digest(); + it('should fire off the enter animation', + inject(function($compile, $rootScope, $templateCache, $animate) { + var item; + + $templateCache.put('enter', [200, '
          data
          ', {}]); + $rootScope.tpl = 'enter'; + element = $compile(html( + '
          ' + + '
          ' + ))($rootScope); + $rootScope.$digest(); + + var animation = $animate.queue.pop(); + expect(animation.event).toBe('enter'); + expect(animation.element.text()).toBe('data'); + }) + ); + + it('should fire off the leave animation', + inject(function($compile, $rootScope, $templateCache, $animate) { + var item; + $templateCache.put('enter', [200, '
          data
          ', {}]); + $rootScope.tpl = 'enter'; + element = $compile(html( + '
          ' + + '
          ' + ))($rootScope); + $rootScope.$digest(); + + var animation = $animate.queue.shift(); + expect(animation.event).toBe('enter'); + expect(animation.element.text()).toBe('data'); + + $rootScope.tpl = ''; + $rootScope.$digest(); + + animation = $animate.queue.shift(); + expect(animation.event).toBe('leave'); + expect(animation.element.text()).toBe('data'); + }) + ); + + it('should animate two separate ngInclude elements', + inject(function($compile, $rootScope, $templateCache, $animate) { + var item; + $templateCache.put('one', [200, 'one', {}]); + $templateCache.put('two', [200, 'two', {}]); + $rootScope.tpl = 'one'; + element = $compile(html( + '
          ' + + '
          ' + ))($rootScope); + $rootScope.$digest(); + + var item1 = $animate.queue.shift().element; + expect(item1.text()).toBe('one'); + + $rootScope.tpl = 'two'; + $rootScope.$digest(); + + var itemA = $animate.queue.shift().element; + var itemB = $animate.queue.shift().element; + expect(itemA.attr('ng-include')).toBe('tpl'); + expect(itemB.attr('ng-include')).toBe('tpl'); + expect(itemA).not.toEqual(itemB); + }) + ); + + it('should destroy the previous leave animation if a new one takes place', function() { + module(function($provide) { + $provide.decorator('$animate', function($delegate, $$q) { + var emptyPromise = $$q.defer().promise; + emptyPromise.done = noop; + + $delegate.leave = function() { + return emptyPromise; + }; + return $delegate; + }); + }); + inject(function($compile, $rootScope, $animate, $templateCache) { + var item; + var $scope = $rootScope.$new(); + element = $compile(html( + '
          ' + + '
          Yo
          ' + + '
          ' + ))($scope); - var item1 = $animate.queue.shift().element; - expect(item1.text()).toBe('one'); + $templateCache.put('one', [200, '
          one
          ', {}]); + $templateCache.put('two', [200, '
          two
          ', {}]); - $rootScope.tpl = 'two'; - $rootScope.$digest(); + $scope.$apply('inc = "one"'); - var itemA = $animate.queue.shift().element; - var itemB = $animate.queue.shift().element; - expect(itemA.attr('ng-include')).toBe('tpl'); - expect(itemB.attr('ng-include')).toBe('tpl'); - expect(itemA).not.toEqual(itemB); - }) - ); - - it('should destroy the previous leave animation if a new one takes place', function() { - module(function($provide) { - $provide.decorator('$animate', function($delegate, $$q) { - var emptyPromise = $$q.defer().promise; - $delegate.leave = function() { - return emptyPromise; - }; - return $delegate; - }); - }); - inject(function($compile, $rootScope, $animate, $templateCache) { - var item; - var $scope = $rootScope.$new(); - element = $compile(html( - '
          ' + - '
          Yo
          ' + - '
          ' - ))($scope); - - $templateCache.put('one', [200, '
          one
          ', {}]); - $templateCache.put('two', [200, '
          two
          ', {}]); - - $scope.$apply('inc = "one"'); - - var destroyed, inner = element.children(0); - inner.on('$destroy', function() { - destroyed = true; - }); + var destroyed, inner = element.children(0); + inner.on('$destroy', function() { + destroyed = true; + }); - $scope.$apply('inc = "two"'); + $scope.$apply('inc = "two"'); - $scope.$apply('inc = "one"'); + $scope.$apply('inc = "one"'); - $scope.$apply('inc = "two"'); + $scope.$apply('inc = "two"'); - expect(destroyed).toBe(true); + expect(destroyed).toBe(true); + }); }); }); }); diff --git a/test/ng/directive/ngInitSpec.js b/test/ng/directive/ngInitSpec.js index 9ed930ad39eb..60cab60e69a4 100644 --- a/test/ng/directive/ngInitSpec.js +++ b/test/ng/directive/ngInitSpec.js @@ -9,13 +9,13 @@ describe('ngInit', function() { }); - it("should init model", inject(function($rootScope, $compile) { + it('should init model', inject(function($rootScope, $compile) { element = $compile('
          ')($rootScope); expect($rootScope.a).toEqual(123); })); - it("should be evaluated before ngInclude", inject(function($rootScope, $templateCache, $compile) { + it('should be evaluated before ngInclude', inject(function($rootScope, $templateCache, $compile) { $templateCache.put('template1.tpl', '1'); $templateCache.put('template2.tpl', '2'); $rootScope.template = 'template1.tpl'; @@ -27,7 +27,7 @@ describe('ngInit', function() { })); - it("should be evaluated after ngController", function() { + it('should be evaluated after ngController', function() { module(function($controllerProvider) { $controllerProvider.register('TestCtrl', function($scope) {}); }); diff --git a/test/ng/directive/ngListSpec.js b/test/ng/directive/ngListSpec.js index dd06913ba029..3ea606978345 100644 --- a/test/ng/directive/ngListSpec.js +++ b/test/ng/directive/ngListSpec.js @@ -1,19 +1,12 @@ 'use strict'; -/* globals getInputCompileHelper: false */ +/* globals generateInputCompilerHelper: false */ describe('ngList', function() { - var helper, $rootScope; - - beforeEach(function() { - helper = getInputCompileHelper(this); - }); - - afterEach(function() { - helper.dealoc(); - }); + var helper = {}, $rootScope; + generateInputCompilerHelper(helper); beforeEach(inject(function(_$rootScope_) { $rootScope = _$rootScope_; @@ -23,7 +16,7 @@ describe('ngList', function() { var inputElm = helper.compileInput(''); // model -> view - $rootScope.$apply("list = ['x', 'y', 'z']"); + $rootScope.$apply('list = [\'x\', \'y\', \'z\']'); expect(inputElm.val()).toBe('x, y, z'); // view -> model @@ -32,7 +25,7 @@ describe('ngList', function() { }); - it("should not clobber text if model changes due to itself", function() { + it('should not clobber text if model changes due to itself', function() { // When the user types 'a,b' the 'a,' stage parses to ['a'] but if the // $parseModel function runs it will change to 'a', in essence preventing // the user from ever typing ','. @@ -86,7 +79,7 @@ describe('ngList', function() { }); - it("should join the list back together with the custom separator", function() { + it('should join the list back together with the custom separator', function() { var inputElm = helper.compileInput(''); $rootScope.$apply(function() { @@ -126,17 +119,23 @@ describe('ngList', function() { }); - it("should not trim whitespace from each list item", function() { + it('should not trim whitespace from each list item', function() { helper.compileInput(''); helper.changeInputValueTo('a | b'); expect($rootScope.list).toEqual(['a ',' b']); }); - it("should support splitting on newlines", function() { - helper.compileInput(''); helper.changeInputValueTo('a\nb'); expect($rootScope.list).toEqual(['a','b']); }); + + it('should support splitting on whitespace', function() { + helper.compileInput(''); + helper.changeInputValueTo('a b'); + expect($rootScope.list).toEqual(['a','b']); + }); }); }); diff --git a/test/ng/directive/ngModelOptionsSpec.js b/test/ng/directive/ngModelOptionsSpec.js new file mode 100644 index 000000000000..09a9ad5f4a7c --- /dev/null +++ b/test/ng/directive/ngModelOptionsSpec.js @@ -0,0 +1,940 @@ +'use strict'; + +/* globals + generateInputCompilerHelper: false, + defaultModelOptions: false + */ +describe('ngModelOptions', function() { + + describe('defaultModelOptions', function() { + it('should provide default values', function() { + expect(defaultModelOptions.getOption('updateOn')).toEqual(''); + expect(defaultModelOptions.getOption('updateOnDefault')).toEqual(true); + expect(defaultModelOptions.getOption('debounce')).toBe(0); + expect(defaultModelOptions.getOption('getterSetter')).toBe(false); + expect(defaultModelOptions.getOption('allowInvalid')).toBe(false); + expect(defaultModelOptions.getOption('timezone')).toBe(null); + }); + }); + + describe('directive', function() { + + describe('basic usage', function() { + + var helper = {}, $rootScope, $compile, $timeout, $q; + + generateInputCompilerHelper(helper); + + beforeEach(inject(function(_$compile_, _$rootScope_, _$timeout_, _$q_) { + $compile = _$compile_; + $rootScope = _$rootScope_; + $timeout = _$timeout_; + $q = _$q_; + })); + + + describe('should fall back to `defaultModelOptions`', function() { + it('if there is no `ngModelOptions` directive', function() { + var inputElm = helper.compileInput( + ''); + + var inputOptions = $rootScope.form.alias.$options; + expect(inputOptions.getOption('updateOn')).toEqual(defaultModelOptions.getOption('updateOn')); + expect(inputOptions.getOption('updateOnDefault')).toEqual(defaultModelOptions.getOption('updateOnDefault')); + expect(inputOptions.getOption('debounce')).toEqual(defaultModelOptions.getOption('debounce')); + expect(inputOptions.getOption('getterSetter')).toEqual(defaultModelOptions.getOption('getterSetter')); + expect(inputOptions.getOption('allowInvalid')).toEqual(defaultModelOptions.getOption('allowInvalid')); + expect(inputOptions.getOption('timezone')).toEqual(defaultModelOptions.getOption('timezone')); + }); + + + it('if `ngModelOptions` on the same element does not specify the option', function() { + var inputElm = helper.compileInput( + ''); + + var inputOptions = $rootScope.form.alias.$options; + expect(inputOptions.getOption('debounce')).toEqual(defaultModelOptions.getOption('debounce')); + expect(inputOptions.getOption('updateOnDefault')).toBe(false); + expect(inputOptions.getOption('updateOnDefault')).not.toEqual(defaultModelOptions.getOption('updateOnDefault')); + }); + + + it('if the first `ngModelOptions` ancestor does not specify the option', function() { + var form = $compile('
          ' + + '' + + '
          ')($rootScope); + var inputOptions = $rootScope.form.alias.$options; + + expect(inputOptions.getOption('debounce')).toEqual(defaultModelOptions.getOption('debounce')); + expect(inputOptions.getOption('updateOnDefault')).toBe(false); + expect(inputOptions.getOption('updateOnDefault')).not.toEqual(defaultModelOptions.getOption('updateOnDefault')); + dealoc(form); + }); + }); + + + describe('sharing and inheritance', function() { + + it('should not inherit options from ancestor `ngModelOptions` directives by default', function() { + var container = $compile( + '
          ' + + '
          ' + + '' + + '
          ' + + '
          ')($rootScope); + + var form = container.find('form'); + var input = container.find('input'); + + var containerOptions = container.controller('ngModelOptions').$options; + var formOptions = form.controller('ngModelOptions').$options; + var inputOptions = input.controller('ngModelOptions').$options; + + expect(containerOptions.getOption('allowInvalid')).toEqual(true); + expect(formOptions.getOption('allowInvalid')).toEqual(false); + expect(inputOptions.getOption('allowInvalid')).toEqual(false); + + expect(containerOptions.getOption('updateOn')).toEqual(''); + expect(containerOptions.getOption('updateOnDefault')).toEqual(true); + expect(formOptions.getOption('updateOn')).toEqual('blur'); + expect(formOptions.getOption('updateOnDefault')).toEqual(false); + expect(inputOptions.getOption('updateOn')).toEqual(''); + expect(inputOptions.getOption('updateOnDefault')).toEqual(true); + + dealoc(container); + }); + + it('should inherit options that are marked with "$inherit" from the nearest ancestor `ngModelOptions` directive', function() { + var container = $compile( + '
          ' + + '
          ' + + '' + + '
          ' + + '
          ')($rootScope); + + var form = container.find('form'); + var input = container.find('input'); + + var containerOptions = container.controller('ngModelOptions').$options; + var formOptions = form.controller('ngModelOptions').$options; + var inputOptions = input.controller('ngModelOptions').$options; + + expect(containerOptions.getOption('allowInvalid')).toEqual(true); + expect(formOptions.getOption('allowInvalid')).toEqual(true); + expect(inputOptions.getOption('allowInvalid')).toEqual(false); + + expect(containerOptions.getOption('updateOn')).toEqual(''); + expect(containerOptions.getOption('updateOnDefault')).toEqual(true); + expect(formOptions.getOption('updateOn')).toEqual('blur'); + expect(formOptions.getOption('updateOnDefault')).toEqual(false); + expect(inputOptions.getOption('updateOn')).toEqual(''); + expect(inputOptions.getOption('updateOnDefault')).toEqual(true); + + dealoc(container); + }); + + it('should inherit all unspecified options if the options object contains a `"*"` property with value "$inherit"', function() { + var container = $compile( + '
          ' + + '
          ' + + '' + + '
          ' + + '
          ')($rootScope); + + var form = container.find('form'); + var input = container.find('input'); + + var containerOptions = container.controller('ngModelOptions').$options; + var formOptions = form.controller('ngModelOptions').$options; + var inputOptions = input.controller('ngModelOptions').$options; + + expect(containerOptions.getOption('allowInvalid')).toEqual(true); + expect(formOptions.getOption('allowInvalid')).toEqual(true); + expect(inputOptions.getOption('allowInvalid')).toEqual(false); + + expect(containerOptions.getOption('debounce')).toEqual(100); + expect(formOptions.getOption('debounce')).toEqual(100); + expect(inputOptions.getOption('debounce')).toEqual(0); + + expect(containerOptions.getOption('updateOn')).toEqual('keyup'); + expect(containerOptions.getOption('updateOnDefault')).toEqual(false); + expect(formOptions.getOption('updateOn')).toEqual('blur'); + expect(formOptions.getOption('updateOnDefault')).toEqual(false); + expect(inputOptions.getOption('updateOn')).toEqual(''); + expect(inputOptions.getOption('updateOnDefault')).toEqual(true); + + dealoc(container); + }); + + it('should correctly inherit default and another specified event for `updateOn`', function() { + var container = $compile( + '
          ' + + '' + + '
          ')($rootScope); + + var input = container.find('input'); + var inputOptions = input.controller('ngModelOptions').$options; + + expect(inputOptions.getOption('updateOn')).toEqual('blur'); + expect(inputOptions.getOption('updateOnDefault')).toEqual(true); + + dealoc(container); + }); + + + it('should `updateOnDefault` as well if we have `updateOn: "$inherit"`', function() { + var container = $compile( + '
          ' + + '' + + '
          ' + + '' + + '
          ' + + '
          ')($rootScope); + + var input1 = container.find('input').eq(0); + var inputOptions1 = input1.controller('ngModelOptions').$options; + + expect(inputOptions1.getOption('updateOn')).toEqual('keyup'); + expect(inputOptions1.getOption('updateOnDefault')).toEqual(false); + + var input2 = container.find('input').eq(1); + var inputOptions2 = input2.controller('ngModelOptions').$options; + + expect(inputOptions2.getOption('updateOn')).toEqual('blur'); + expect(inputOptions2.getOption('updateOnDefault')).toEqual(true); + + dealoc(container); + }); + + + it('should make a copy of the options object', function() { + $rootScope.options = {updateOn: 'default'}; + var inputElm = helper.compileInput( + ''); + expect($rootScope.options).toEqual({updateOn: 'default'}); + expect($rootScope.form.alias.$options).not.toBe($rootScope.options); + }); + + it('should be retrieved from an ancestor element containing an `ngModelOptions` directive', function() { + var doc = $compile( + '
          ' + + '' + + '
          ')($rootScope); + $rootScope.$digest(); + + var inputElm = doc.find('input'); + helper.changeGivenInputTo(inputElm, 'a'); + expect($rootScope.name).toEqual(undefined); + browserTrigger(inputElm, 'blur'); + expect($rootScope.name).toBeUndefined(); + $timeout.flush(2000); + expect($rootScope.name).toBeUndefined(); + $timeout.flush(9000); + expect($rootScope.name).toEqual('a'); + dealoc(doc); + }); + + it('should allow sharing options between multiple inputs', function() { + $rootScope.options = {updateOn: 'default'}; + var inputElm = helper.compileInput( + '' + + ''); + + helper.changeGivenInputTo(inputElm.eq(0), 'a'); + helper.changeGivenInputTo(inputElm.eq(1), 'b'); + expect($rootScope.name1).toEqual('a'); + expect($rootScope.name2).toEqual('b'); + }); + }); + + + describe('updateOn', function() { + it('should allow overriding the model update trigger event on text inputs', function() { + var inputElm = helper.compileInput( + ''); + + helper.changeInputValueTo('a'); + expect($rootScope.name).toBeUndefined(); + browserTrigger(inputElm, 'blur'); + expect($rootScope.name).toEqual('a'); + }); + + + it('should not dirty the input if nothing was changed before updateOn trigger', function() { + var inputElm = helper.compileInput( + ''); + + browserTrigger(inputElm, 'blur'); + expect($rootScope.form.alias.$pristine).toBeTruthy(); + }); + + + it('should allow overriding the model update trigger event on text areas', function() { + var inputElm = helper.compileInput( + '', + button: '', + summary: '', + details: '
          ', + a: '' + }, function(tmpl) { + var element = $compile(tmpl)(scope); + expect(element.attr('role')).toBeUndefined(); + }); + }); + describe('aria-checked when disabled', function() { beforeEach(configAriaProvider({ ariaChecked: false @@ -195,16 +541,16 @@ describe('$aria', function() { beforeEach(injectScopeAndCompiler); it('should not attach aria-checked', function() { - compileInput("
          "); + compileElement('
          '); expect(element.attr('aria-checked')).toBeUndefined(); - compileInput("
          "); + compileElement('
          '); expect(element.attr('aria-checked')).toBeUndefined(); - compileInput("
          "); + compileElement('
          '); expect(element.attr('aria-checked')).toBeUndefined(); - compileInput("
          "); + compileElement('
          '); expect(element.attr('aria-checked')).toBeUndefined(); }); }); @@ -212,52 +558,46 @@ describe('$aria', function() { describe('aria-disabled', function() { beforeEach(injectScopeAndCompiler); - it('should attach itself to input elements', function() { - scope.$apply('val = false'); - compileInput(""); - expect(element.attr('aria-disabled')).toBe('false'); - + they('should not attach itself to native $prop controls', { + input: '', + textarea: '', + select: '', + button: '' + }, function(tmpl) { + var element = $compile(tmpl)(scope); scope.$apply('val = true'); - expect(element.attr('aria-disabled')).toBe('true'); + + expect(element.attr('disabled')).toBeDefined(); + expect(element.attr('aria-disabled')).toBeUndefined(); }); - it('should attach itself to textarea elements', function() { - scope.$apply('val = false'); - compileInput(''); + it('should attach itself to custom controls', function() { + compileElement('
          '); expect(element.attr('aria-disabled')).toBe('false'); scope.$apply('val = true'); expect(element.attr('aria-disabled')).toBe('true'); + }); - it('should attach itself to button elements', function() { - scope.$apply('val = false'); - compileInput(''); - expect(element.attr('aria-disabled')).toBe('false'); + it('should not attach itself if an aria-disabled attribute is already present', function() { + compileElement('
          '); - scope.$apply('val = true'); - expect(element.attr('aria-disabled')).toBe('true'); + expect(element.attr('aria-disabled')).toBe('userSetValue'); }); - it('should attach itself to select elements', function() { - scope.$apply('val = false'); - compileInput(''); - expect(element.attr('aria-disabled')).toBe('false'); - scope.$apply('val = true'); + it('should always set aria-disabled to a boolean value', function() { + compileElement('
          '); + + scope.$apply('val = "test angular"'); expect(element.attr('aria-disabled')).toBe('true'); - }); - it('should not attach itself if an aria-disabled attribute is already present', function() { - var element = [ - $compile("")(scope), - $compile("")(scope), - $compile("")(scope), - $compile("")(scope) - ]; + scope.$apply('val = null'); + expect(element.attr('aria-disabled')).toBe('false'); - scope.$apply('val = true'); - expectAriaAttrOnEachElement(element, 'aria-disabled', 'userSetValue'); + scope.$apply('val = {}'); + expect(element.attr('aria-disabled')).toBe('true'); }); }); @@ -268,15 +608,10 @@ describe('$aria', function() { beforeEach(injectScopeAndCompiler); it('should not attach aria-disabled', function() { - var element = [ - $compile("")(scope), - $compile("")(scope), - $compile("")(scope), - $compile("")(scope) - ]; + compileElement('
          '); - scope.$apply('val = false'); - expectAriaAttrOnEachElement(element, 'aria-disabled', undefined); + scope.$apply('val = true'); + expect(element.attr('aria-disabled')).toBeUndefined(); }); }); @@ -284,19 +619,43 @@ describe('$aria', function() { beforeEach(injectScopeAndCompiler); it('should attach aria-invalid to input', function() { - compileInput(''); - scope.$apply("txtInput='LTten'"); + compileElement(''); + scope.$apply('txtInput=\'LTten\''); + expect(element.attr('aria-invalid')).toBe('true'); + + scope.$apply('txtInput=\'morethantencharacters\''); + expect(element.attr('aria-invalid')).toBe('false'); + }); + + it('should attach aria-invalid to custom controls', function() { + compileElement('
          '); + scope.$apply('txtInput=\'LTten\''); expect(element.attr('aria-invalid')).toBe('true'); - scope.$apply("txtInput='morethantencharacters'"); + scope.$apply('txtInput=\'morethantencharacters\''); expect(element.attr('aria-invalid')).toBe('false'); }); it('should not attach itself if aria-invalid is already present', function() { - compileInput(''); - scope.$apply("txtInput='LTten'"); + compileElement(''); + scope.$apply('txtInput=\'LTten\''); expect(element.attr('aria-invalid')).toBe('userSetValue'); }); + + it('should not attach if input is type="hidden"', function() { + compileElement(''); + expect(element.attr('aria-invalid')).toBeUndefined(); + }); + + + it('should attach aria-invalid to custom control that is type="hidden"', function() { + compileElement('
          '); + scope.$apply('txtInput=\'LTten\''); + expect(element.attr('aria-invalid')).toBe('true'); + + scope.$apply('txtInput=\'morethantencharacters\''); + expect(element.attr('aria-invalid')).toBe('false'); + }); }); describe('aria-invalid when disabled', function() { @@ -306,116 +665,113 @@ describe('$aria', function() { beforeEach(injectScopeAndCompiler); it('should not attach aria-invalid if the option is disabled', function() { - scope.$apply("txtInput='LTten'"); - compileInput(''); + scope.$apply('txtInput=\'LTten\''); + compileElement(''); expect(element.attr('aria-invalid')).toBeUndefined(); }); }); - describe('aria-required', function() { + describe('aria-readonly', function() { beforeEach(injectScopeAndCompiler); - it('should attach aria-required to input', function() { - compileInput(''); - expect(element.attr('aria-required')).toBe('true'); + they('should not attach itself to native $prop controls', { + input: '', + textarea: '', + select: '', + button: '' + }, function(tmpl) { + var element = $compile(tmpl)(scope); + scope.$apply('val = true'); - scope.$apply("val='input is valid now'"); - expect(element.attr('aria-required')).toBe('false'); + expect(element.attr('readonly')).toBeDefined(); + expect(element.attr('aria-readonly')).toBeUndefined(); }); - it('should attach aria-required to textarea', function() { - compileInput(''); - expect(element.attr('aria-required')).toBe('true'); - - scope.$apply("val='input is valid now'"); - expect(element.attr('aria-required')).toBe('false'); - }); + it('should attach itself to custom controls', function() { + compileElement('
          '); + expect(element.attr('aria-readonly')).toBe('false'); - it('should attach aria-required to select', function() { - compileInput(''); - expect(element.attr('aria-required')).toBe('true'); + scope.$apply('val = true'); + expect(element.attr('aria-readonly')).toBe('true'); - scope.$apply("val='input is valid now'"); - expect(element.attr('aria-required')).toBe('false'); }); - it('should attach aria-required to ngRequired', function() { - compileInput(''); - expect(element.attr('aria-required')).toBe('true'); + it('should not attach itself if an aria-readonly attribute is already present', function() { + compileElement('
          '); - scope.$apply("val='input is valid now'"); - expect(element.attr('aria-required')).toBe('false'); + expect(element.attr('aria-readonly')).toBe('userSetValue'); }); - it('should not attach itself if aria-required is already present', function() { - compileInput(""); - expect(element.attr('aria-required')).toBe('userSetValue'); + it('should always set aria-readonly to a boolean value', function() { + compileElement('
          '); - compileInput(""); - expect(element.attr('aria-required')).toBe('userSetValue'); + scope.$apply('val = "test angular"'); + expect(element.attr('aria-readonly')).toBe('true'); - compileInput(""); - expect(element.attr('aria-required')).toBe('userSetValue'); + scope.$apply('val = null'); + expect(element.attr('aria-readonly')).toBe('false'); - compileInput(""); - expect(element.attr('aria-required')).toBe('userSetValue'); + scope.$apply('val = {}'); + expect(element.attr('aria-readonly')).toBe('true'); }); }); - describe('aria-required when disabled', function() { + describe('aria-readonly when disabled', function() { beforeEach(configAriaProvider({ - ariaRequired: false + ariaReadonly: false })); beforeEach(injectScopeAndCompiler); - it('should not add the aria-required attribute', function() { - compileInput(""); - expect(element.attr('aria-required')).toBeUndefined(); + it('should not add the aria-readonly attribute', function() { + compileElement(''); + expect(element.attr('aria-readonly')).toBeUndefined(); - compileInput(""); - expect(element.attr('aria-required')).toBeUndefined(); - - compileInput(""); - expect(element.attr('aria-required')).toBeUndefined(); + compileElement('
          '); + expect(element.attr('aria-readonly')).toBeUndefined(); }); }); - describe('aria-multiline', function() { + describe('aria-required', function() { beforeEach(injectScopeAndCompiler); - it('should attach itself to textarea', function() { - compileInput(''); - expect(element.attr('aria-multiline')).toBe('true'); + it('should not attach to input', function() { + compileElement(''); + expect(element.attr('aria-required')).toBeUndefined(); + }); + + it('should attach to custom controls with ngModel and required', function() { + compileElement('
          '); + expect(element.attr('aria-required')).toBe('true'); }); - it('should attach itself role="textbox"', function() { - compileInput('
          '); - expect(element.attr('aria-multiline')).toBe('true'); + it('should set aria-required to false when ng-required is false', function() { + compileElement('
          '); + expect(element.attr('aria-required')).toBe('false'); }); - it('should not attach itself if aria-multiline is already present', function() { - compileInput(''); - expect(element.attr('aria-multiline')).toBe('userSetValue'); + it('should attach to custom controls with ngRequired', function() { + compileElement('
          '); + expect(element.attr('aria-required')).toBe('true'); + }); - compileInput('
          '); - expect(element.attr('aria-multiline')).toBe('userSetValue'); + it('should not attach itself if aria-required is already present', function() { + compileElement('
          '); + expect(element.attr('aria-required')).toBe('userSetValue'); }); }); - describe('aria-multiline when disabled', function() { + describe('aria-required when disabled', function() { beforeEach(configAriaProvider({ - ariaMultiline: false + ariaRequired: false })); beforeEach(injectScopeAndCompiler); - it('should not attach itself to textarea', function() { - compileInput(''); - expect(element.attr('aria-multiline')).toBeUndefined(); - }); + it('should not add the aria-required attribute', function() { + compileElement(''); + expect(element.attr('aria-required')).toBeUndefined(); - it('should not attach itself role="textbox"', function() { - compileInput('
          '); - expect(element.attr('aria-multiline')).toBeUndefined(); + compileElement('
          '); + expect(element.attr('aria-required')).toBeUndefined(); }); }); @@ -430,12 +786,12 @@ describe('$aria', function() { ]; scope.$apply('val = 50'); - expectAriaAttrOnEachElement(element, 'aria-valuenow', "50"); - expectAriaAttrOnEachElement(element, 'aria-valuemin', "0"); - expectAriaAttrOnEachElement(element, 'aria-valuemax', "100"); + expectAriaAttrOnEachElement(element, 'aria-valuenow', '50'); + expectAriaAttrOnEachElement(element, 'aria-valuemin', '0'); + expectAriaAttrOnEachElement(element, 'aria-valuemax', '100'); scope.$apply('val = 90'); - expectAriaAttrOnEachElement(element, 'aria-valuenow', "90"); + expectAriaAttrOnEachElement(element, 'aria-valuenow', '90'); }); it('should not attach if aria-value* is already present', function() { @@ -450,6 +806,36 @@ describe('$aria', function() { expectAriaAttrOnEachElement(element, 'aria-valuemin', 'userSetValue2'); expectAriaAttrOnEachElement(element, 'aria-valuemax', 'userSetValue3'); }); + + + it('should update `aria-valuemin/max` when `min/max` changes dynamically', function() { + scope.$apply('min = 25; max = 75'); + compileElement(''); + + expect(element.attr('aria-valuemin')).toBe('25'); + expect(element.attr('aria-valuemax')).toBe('75'); + + scope.$apply('min = 0'); + expect(element.attr('aria-valuemin')).toBe('0'); + + scope.$apply('max = 100'); + expect(element.attr('aria-valuemax')).toBe('100'); + }); + + + it('should update `aria-valuemin/max` when `ng-min/ng-max` changes dynamically', function() { + scope.$apply('min = 25; max = 75'); + compileElement(''); + + expect(element.attr('aria-valuemin')).toBe('25'); + expect(element.attr('aria-valuemax')).toBe('75'); + + scope.$apply('min = 0'); + expect(element.attr('aria-valuemin')).toBe('0'); + + scope.$apply('max = 100'); + expect(element.attr('aria-valuemax')).toBe('100'); + }); }); describe('announcing ngMessages', function() { @@ -459,7 +845,7 @@ describe('$aria', function() { var element = [ $compile('
          ')(scope) ]; - expectAriaAttrOnEachElement(element, 'aria-live', "assertive"); + expectAriaAttrOnEachElement(element, 'aria-live', 'assertive'); }); }); @@ -472,12 +858,12 @@ describe('$aria', function() { it('should not attach itself', function() { scope.$apply('val = 50'); - compileInput(''); + compileElement(''); expect(element.attr('aria-valuenow')).toBeUndefined(); expect(element.attr('aria-valuemin')).toBeUndefined(); expect(element.attr('aria-valuemax')).toBeUndefined(); - compileInput('
          '); + compileElement('
          '); expect(element.attr('aria-valuenow')).toBeUndefined(); expect(element.attr('aria-valuemin')).toBeUndefined(); expect(element.attr('aria-valuemax')).toBeUndefined(); @@ -487,133 +873,305 @@ describe('$aria', function() { describe('tabindex', function() { beforeEach(injectScopeAndCompiler); - it('should attach tabindex to role="checkbox", ng-click, and ng-dblclick', function() { - compileInput('
          '); + they('should not attach to native control $prop', { + 'button': '', + 'a': '', + 'input[text]': '', + 'input[radio]': '', + 'input[checkbox]': '', + 'textarea': '', + 'select': '', + 'details': '
          ' + }, function(html) { + compileElement(html); + expect(element.attr('tabindex')).toBeUndefined(); + }); + + it('should not attach to random ng-model elements', function() { + compileElement('
          '); + expect(element.attr('tabindex')).toBeUndefined(); + }); + + it('should attach tabindex to custom inputs', function() { + compileElement('
          '); + expect(element.attr('tabindex')).toBe('0'); + + compileElement('
          '); expect(element.attr('tabindex')).toBe('0'); + }); - compileInput('
          '); + it('should attach to ng-click and ng-dblclick', function() { + compileElement('
          '); expect(element.attr('tabindex')).toBe('0'); - compileInput('
          '); + compileElement('
          '); expect(element.attr('tabindex')).toBe('0'); }); it('should not attach tabindex if it is already on an element', function() { - compileInput('
          '); + compileElement('
          '); expect(element.attr('tabindex')).toBe('userSetValue'); - compileInput('
          '); + compileElement('
          '); expect(element.attr('tabindex')).toBe('userSetValue'); - compileInput('
          '); + compileElement('
          '); expect(element.attr('tabindex')).toBe('userSetValue'); - compileInput('
          '); + compileElement('
          '); expect(element.attr('tabindex')).toBe('userSetValue'); }); - - it('should set proper tabindex values for radiogroup', function() { - compileInput('
          ' + - '
          1
          ' + - '
          2
          ' + - '
          '); - - var one = element.contents().eq(0); - var two = element.contents().eq(1); - - scope.$apply("val = 'one'"); - expect(one.attr('tabindex')).toBe('0'); - expect(two.attr('tabindex')).toBe('-1'); - - scope.$apply("val = 'two'"); - expect(one.attr('tabindex')).toBe('-1'); - expect(two.attr('tabindex')).toBe('0'); - - dealoc(element); - }); }); describe('accessible actions', function() { + var clickEvents; + beforeEach(injectScopeAndCompiler); + beforeEach(function() { + clickEvents = []; + scope.onClick = jasmine.createSpy('onClick').and.callFake(function(evt) { + var nodeName = evt ? evt.target.nodeName.toLowerCase() : ''; + var prevented = !!(evt && evt.isDefaultPrevented()); + clickEvents.push(nodeName + '(' + prevented + ')'); + }); + }); - var clickFn; + it('should trigger a click from the keyboard (and prevent default action)', function() { + compileElement( + '
          ' + + '
          ' + + '
          ' + + '
          '); - it('should a trigger click from the keyboard', function() { - scope.someAction = function() {}; + var divElement = element.find('div'); + var liElement = element.find('li'); - var elements = $compile('
          ' + - '
          ' + - '
          ' + - '
          ')(scope); + divElement.triggerHandler({type: 'keydown', keyCode: 13}); + liElement.triggerHandler({type: 'keydown', keyCode: 13}); + divElement.triggerHandler({type: 'keydown', keyCode: 32}); + liElement.triggerHandler({type: 'keydown', keyCode: 32}); - scope.$digest(); + expect(clickEvents).toEqual(['div(true)', 'li(true)', 'div(true)', 'li(true)']); + }); - clickFn = spyOn(scope, 'someAction'); + it('should trigger a click in browsers that provide `event.which` instead of `event.keyCode`', + function() { + compileElement( + '
          ' + + '
          ' + + '
          ' + + '
          '); + + var divElement = element.find('div'); + var liElement = element.find('li'); + + divElement.triggerHandler({type: 'keydown', which: 13}); + liElement.triggerHandler({type: 'keydown', which: 13}); + divElement.triggerHandler({type: 'keydown', which: 32}); + liElement.triggerHandler({type: 'keydown', which: 32}); + + expect(clickEvents).toEqual(['div(true)', 'li(true)', 'div(true)', 'li(true)']); + } + ); + + it('should not prevent default keyboard action if the target element has editable content', + inject(function($document) { + // Note: + // `contenteditable` is an enumarated (not a boolean) attribute (see + // https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/contenteditable). + // We need to check the following conditions: + // - No attribute. + // - Value: "" + // - Value: "true" + // - Value: "false" + + function eventFor(keyCode) { + return {bubbles: true, cancelable: true, keyCode: keyCode}; + } + + compileElement( + '
          ' + + // No attribute. + '
          ' + + '
          ' + + '
          ' + + '
          ' + + + // Value: "" + '
          ' + + '
          ' + + '
          ' + + '
          ' + + + // Value: "true" + '
          ' + + '
          ' + + '
          ' + + '
          ' + + + // Value: "false" + '
          ' + + '
          ' + + '
          ' + + '
          ' + + '
          '); + + // Support: Safari 11-12+ + // Attach to DOM, because otherwise Safari will not update the `isContentEditable` property + // based on the `contenteditable` attribute. + $document.find('body').append(element); + + var containers = element.children(); + var container; + + // Using `browserTrigger()`, because it supports event bubbling. + + // No attribute | Elements are not editable. + container = containers.eq(0); + browserTrigger(container.find('div'), 'keydown', eventFor(13)); + browserTrigger(container.find('ul'), 'keydown', eventFor(32)); + browserTrigger(container.find('li'), 'keydown', eventFor(13)); + + expect(clickEvents).toEqual(['div(true)', 'ul(true)', 'li(true)']); + + // Value: "" | Elements are editable. + clickEvents = []; + container = containers.eq(1); + browserTrigger(container.find('div'), 'keydown', eventFor(32)); + browserTrigger(container.find('ul'), 'keydown', eventFor(13)); + browserTrigger(container.find('li'), 'keydown', eventFor(32)); + + expect(clickEvents).toEqual(['div(false)', 'ul(true)', 'li(false)']); + + // Value: "true" | Elements are editable. + clickEvents = []; + container = containers.eq(2); + browserTrigger(container.find('div'), 'keydown', eventFor(13)); + browserTrigger(container.find('ul'), 'keydown', eventFor(32)); + browserTrigger(container.find('li'), 'keydown', eventFor(13)); + + expect(clickEvents).toEqual(['div(false)', 'ul(true)', 'li(false)']); + + // Value: "false" | Elements are not editable. + clickEvents = []; + container = containers.eq(3); + browserTrigger(container.find('div'), 'keydown', eventFor(32)); + browserTrigger(container.find('ul'), 'keydown', eventFor(13)); + browserTrigger(container.find('li'), 'keydown', eventFor(32)); + + expect(clickEvents).toEqual(['div(true)', 'ul(true)', 'li(true)']); + }) + ); + + they('should not prevent default keyboard action if an interactive $type element' + + 'is nested inside ng-click', nativeAriaNodeNames, function(elementType) { + function createHTML(type) { + return '<' + type + '>'; + } + + compileElement( + '
          ' + + '
          ' + createHTML(elementType) + '
          ' + + '
          '); + + var divElement = element.find('div'); + var interactiveElement = element.find(elementType); + + // Use browserTrigger because it supports event bubbling + // 13 Enter + browserTrigger(interactiveElement, 'keydown', {cancelable: true, bubbles: true, keyCode: 13}); + expect(clickEvents).toEqual([elementType.toLowerCase() + '(false)']); + + clickEvents = []; + + // 32 Space + browserTrigger(interactiveElement, 'keydown', {cancelable: true, bubbles: true, keyCode: 32}); + expect(clickEvents).toEqual([elementType.toLowerCase() + '(false)']); + } + ); + + they('should not bind to key events if there is existing `ng-$prop`', + ['keydown', 'keypress', 'keyup'], function(eventName) { + scope.onKeyEvent = jasmine.createSpy('onKeyEvent'); + compileElement('
          '); + + element.triggerHandler({type: eventName, keyCode: 13}); + element.triggerHandler({type: eventName, keyCode: 32}); + + expect(scope.onClick).not.toHaveBeenCalled(); + expect(scope.onKeyEvent).toHaveBeenCalledTimes(2); + } + ); + + it('should update bindings when keydown is handled', function() { + scope.count = 0; + compileElement('
          Count: {{ count }}
          '); + + expect(element.text()).toBe('Count: 0'); + + element.triggerHandler({type: 'keydown', keyCode: 13}); + expect(element.text()).toBe('Count: 1'); + + element.triggerHandler({type: 'keydown', keyCode: 32}); + expect(element.text()).toBe('Count: 2'); + }); - var divElement = elements.find('div'); - var liElement = elements.find('li'); + it('should pass `$event` to `ng-click` handler as local', function() { + compileElement('
          {{ event.type }}{{ event.keyCode }}
          '); + expect(element.text()).toBe(''); - divElement.triggerHandler({type: 'keypress', keyCode: 32}); - liElement.triggerHandler({type: 'keypress', keyCode: 32}); + element.triggerHandler({type: 'keydown', keyCode: 13}); + expect(element.text()).toBe('keydown13'); - expect(clickFn).toHaveBeenCalledWith('div'); - expect(clickFn).toHaveBeenCalledWith('li'); + element.triggerHandler({type: 'keydown', keyCode: 32}); + expect(element.text()).toBe('keydown32'); }); - it('should not override existing ng-keypress', function() { - scope.someOtherAction = function() {}; - var keypressFn = spyOn(scope, 'someOtherAction'); - - scope.someAction = function() {}; - clickFn = spyOn(scope, 'someAction'); - compileInput('
          '); - - element.triggerHandler({type: 'keypress', keyCode: 32}); + it('should not bind keydown to natively interactive elements', function() { + compileElement(''); - expect(clickFn).not.toHaveBeenCalled(); - expect(keypressFn).toHaveBeenCalled(); - }); + element.triggerHandler({type: 'keydown', keyCode: 13}); + element.triggerHandler({type: 'keydown', keyCode: 32}); - it('should update bindings when keypress handled', function() { - compileInput('
          {{text}}
          '); - expect(element.text()).toBe(''); - spyOn(scope.$root, '$digest').andCallThrough(); - element.triggerHandler({ type: 'keypress', keyCode: 13 }); - expect(element.text()).toBe('clicked!'); - expect(scope.$root.$digest).toHaveBeenCalledOnce(); + expect(scope.onClick).not.toHaveBeenCalled(); }); + }); - it('should pass $event to ng-click handler as local', function() { - compileInput('
          {{event.type}}' + - '{{event.keyCode}}
          '); - expect(element.text()).toBe(''); - element.triggerHandler({ type: 'keypress', keyCode: 13 }); - expect(element.text()).toBe('keypress13'); - }); + describe('actions when bindRoleForClick is set to false', function() { + beforeEach(configAriaProvider({ + bindRoleForClick: false + })); + beforeEach(injectScopeAndCompiler); - it('should not bind keypress to elements not in the default config', function() { - compileInput(''); - expect(element.text()).toBe(''); - element.triggerHandler({ type: 'keypress', keyCode: 13 }); - expect(element.text()).toBe(''); + it('should not add a button role', function() { + compileElement(''); + expect(element.attr('role')).toBeUndefined(); }); }); - describe('actions when bindKeypress set to false', function() { + describe('actions when bindKeydown is set to false', function() { beforeEach(configAriaProvider({ - bindKeypress: false + bindKeydown: false })); beforeEach(injectScopeAndCompiler); - it('should not a trigger click', function() { - scope.someAction = function() {}; - var clickFn = spyOn(scope, 'someAction'); + it('should not trigger click', function() { + scope.someAction = jasmine.createSpy('someAction'); element = $compile('
          ')(scope); + element.triggerHandler({type: 'keydown', keyCode: 13}); + element.triggerHandler({type: 'keydown', keyCode: 32}); + element.triggerHandler({type: 'keypress', keyCode: 13}); element.triggerHandler({type: 'keypress', keyCode: 32}); + element.triggerHandler({type: 'keyup', keyCode: 13}); + element.triggerHandler({type: 'keyup', keyCode: 32}); + + expect(scope.someAction).not.toHaveBeenCalled(); + + element.triggerHandler({type: 'click', keyCode: 32}); - expect(clickFn).not.toHaveBeenCalled(); + expect(scope.someAction).toHaveBeenCalledOnce(); }); }); @@ -624,32 +1182,66 @@ describe('$aria', function() { beforeEach(injectScopeAndCompiler); it('should not add a tabindex attribute', function() { - compileInput('
          '); + compileElement('
          '); expect(element.attr('tabindex')).toBeUndefined(); - compileInput('
          '); + compileElement('
          '); expect(element.attr('tabindex')).toBeUndefined(); - compileInput('
          '); + compileElement('
          '); expect(element.attr('tabindex')).toBeUndefined(); - compileInput('
          '); + compileElement('
          '); expect(element.attr('tabindex')).toBeUndefined(); }); }); -}); -function expectAriaAttrOnEachElement(elem, ariaAttr, expected) { - angular.forEach(elem, function(val) { - expect(angular.element(val).attr(ariaAttr)).toBe(expected); + describe('ngModel', function() { + it('should not break when manually compiling', function() { + module(function($compileProvider) { + $compileProvider.directive('foo', function() { + return { + priority: 10, + terminal: true, + link: function(scope, elem) { + $compile(elem, null, 10)(scope); + } + }; + }); + }); + + injectScopeAndCompiler(); + compileElement('
          '); + + // Just check an arbitrary feature to make sure it worked + expect(element.attr('tabindex')).toBe('0'); + }); }); -} -function configAriaProvider(config) { - return function() { - angular.module('ariaTest', ['ngAria']).config(function($ariaProvider) { - $ariaProvider.config(config); + // Helpers + function compileElement(inputHtml) { + element = $compile(inputHtml)(scope); + scope.$digest(); + } + + function configAriaProvider(config) { + return function() { + module(function($ariaProvider) { + $ariaProvider.config(config); + }); + }; + } + + function expectAriaAttrOnEachElement(elem, ariaAttr, expected) { + angular.forEach(elem, function(val) { + expect(angular.element(val).attr(ariaAttr)).toBe(expected); + }); + } + + function injectScopeAndCompiler() { + return inject(function(_$compile_, _$rootScope_) { + $compile = _$compile_; + scope = _$rootScope_; }); - module('ariaTest'); - }; -} + } +}); diff --git a/test/ngCookies/cookieWriterSpec.js b/test/ngCookies/cookieWriterSpec.js new file mode 100644 index 000000000000..71325b0a70bc --- /dev/null +++ b/test/ngCookies/cookieWriterSpec.js @@ -0,0 +1,210 @@ +'use strict'; + +describe('$$cookieWriter', function() { + var $$cookieWriter, document; + + function deleteAllCookies() { + var cookies = document.cookie.split(';'); + var path = window.location.pathname; + + for (var i = 0; i < cookies.length; i++) { + var cookie = cookies[i]; + var eqPos = cookie.indexOf('='); + var name = eqPos > -1 ? cookie.substr(0, eqPos) : cookie; + var parts = path.split('/'); + while (parts.length) { + document.cookie = name + '=;path=' + (parts.join('/') || '/') + ';expires=Thu, 01 Jan 1970 00:00:00 GMT'; + parts.pop(); + } + } + } + + beforeEach(function() { + document = window.document; + deleteAllCookies(); + expect(document.cookie).toEqual(''); + + module('ngCookies'); + inject(function(_$$cookieWriter_) { + $$cookieWriter = _$$cookieWriter_; + }); + }); + + + afterEach(function() { + deleteAllCookies(); + expect(document.cookie).toEqual(''); + }); + + + describe('remove via $$cookieWriter(cookieName, undefined)', function() { + + it('should remove a cookie when it is present', function() { + document.cookie = 'foo=bar;path=/'; + + $$cookieWriter('foo', undefined); + + expect(document.cookie).toEqual(''); + }); + + + it('should do nothing when an nonexisting cookie is being removed', function() { + $$cookieWriter('doesntexist', undefined); + expect(document.cookie).toEqual(''); + }); + }); + + + describe('put via $$cookieWriter(cookieName, string)', function() { + + it('should create and store a cookie', function() { + $$cookieWriter('cookieName', 'cookie=Value'); + expect(document.cookie).toMatch(/cookieName=cookie%3DValue;? ?/); + }); + + + it('should overwrite an existing unsynced cookie', function() { + document.cookie = 'cookie=new;path=/'; + + var oldVal = $$cookieWriter('cookie', 'newer'); + + expect(document.cookie).toEqual('cookie=newer'); + expect(oldVal).not.toBeDefined(); + }); + + it('should encode both name and value', function() { + $$cookieWriter('cookie1=', 'val;ue'); + $$cookieWriter('cookie2=bar;baz', 'val=ue'); + + var rawCookies = document.cookie.split('; '); //order is not guaranteed, so we need to parse + expect(rawCookies.length).toEqual(2); + expect(rawCookies).toContain('cookie1%3D=val%3Bue'); + expect(rawCookies).toContain('cookie2%3Dbar%3Bbaz=val%3Due'); + }); + + it('should log warnings when 4kb per cookie storage limit is reached', inject(function($log) { + var i, longVal = '', cookieStr; + + for (i = 0; i < 4083; i++) { + longVal += 'x'; + } + + cookieStr = document.cookie; + $$cookieWriter('x', longVal); //total size 4093-4096, so it should go through + expect(document.cookie).not.toEqual(cookieStr); + expect(document.cookie).toEqual('x=' + longVal); + expect($log.warn.logs).toEqual([]); + + $$cookieWriter('x', longVal + 'xxxx'); //total size 4097-4099, a warning should be logged + expect($log.warn.logs).toEqual( + [['Cookie \'x\' possibly not set or overflowed because it was too large (4097 > 4096 ' + + 'bytes)!']]); + + //force browser to dropped a cookie and make sure that the cache is not out of sync + $$cookieWriter('x', 'shortVal'); + expect(document.cookie).toEqual('x=shortVal'); //needed to prime the cache + cookieStr = document.cookie; + $$cookieWriter('x', longVal + longVal + longVal); //should be too long for all browsers + + if (document.cookie !== cookieStr) { + this.fail(new Error('browser didn\'t drop long cookie when it was expected. make the ' + + 'cookie in this test longer')); + } + + expect(document.cookie).toEqual('x=shortVal'); + $log.reset(); + })); + }); + + describe('put via $$cookieWriter(cookieName, string), if no ', function() { + beforeEach(inject(function($browser) { + $browser.$$baseHref = undefined; + })); + + it('should default path in cookie to "" (empty string)', function() { + $$cookieWriter('cookie', 'bender'); + // This only fails in Safari and IE when cookiePath returns undefined + // Where it now succeeds since baseHref return '' instead of undefined + expect(document.cookie).toEqual('cookie=bender'); + }); + }); +}); + +describe('cookie options', function() { + var fakeDocument, $$cookieWriter; + var isUndefined = angular.isUndefined; + + function getLastCookieAssignment(key) { + return fakeDocument[0].cookie + .split(';') + .reduce(function(prev, value) { + var pair = value.split('=', 2); + if (pair[0] === key) { + if (isUndefined(prev)) { + return isUndefined(pair[1]) ? true : pair[1]; + } else { + throw new Error('duplicate key in cookie string'); + } + } else { + return prev; + } + }, undefined); + } + + beforeEach(function() { + fakeDocument = [{cookie: ''}]; + module('ngCookies', {$document: fakeDocument}); + inject(function($browser) { + $browser.$$baseHref = '/a/b'; + }); + inject(function(_$$cookieWriter_) { + $$cookieWriter = _$$cookieWriter_; + }); + }); + + it('should use baseHref as default path', function() { + $$cookieWriter('name', 'value'); + expect(getLastCookieAssignment('path')).toBe('/a/b'); + }); + + it('should accept path option', function() { + $$cookieWriter('name', 'value', {path: '/c/d'}); + expect(getLastCookieAssignment('path')).toBe('/c/d'); + }); + + it('should accept domain option', function() { + $$cookieWriter('name', 'value', {domain: '.example.com'}); + expect(getLastCookieAssignment('domain')).toBe('.example.com'); + }); + + it('should accept secure option', function() { + $$cookieWriter('name', 'value', {secure: true}); + expect(getLastCookieAssignment('secure')).toBe(true); + }); + + it('should accept samesite option when value is lax', function() { + $$cookieWriter('name', 'value', {samesite: 'lax'}); + expect(getLastCookieAssignment('samesite')).toBe('lax'); + }); + + it('should accept samesite option when value is strict', function() { + $$cookieWriter('name', 'value', {samesite: 'strict'}); + expect(getLastCookieAssignment('samesite')).toBe('strict'); + }); + + it('should accept expires option on set', function() { + $$cookieWriter('name', 'value', {expires: 'Fri, 19 Dec 2014 00:00:00 GMT'}); + expect(getLastCookieAssignment('expires')).toMatch(/^Fri, 19 Dec 2014 00:00:00 (UTC|GMT)$/); + }); + + it('should always use epoch time as expire time on remove', function() { + $$cookieWriter('name', undefined, {expires: 'Fri, 19 Dec 2014 00:00:00 GMT'}); + expect(getLastCookieAssignment('expires')).toMatch(/^Thu, 0?1 Jan 1970 00:00:00 (UTC|GMT)$/); + }); + + it('should accept date object as expires option', function() { + $$cookieWriter('name', 'value', {expires: new Date(Date.UTC(1981, 11, 27))}); + expect(getLastCookieAssignment('expires')).toMatch(/^Sun, 27 Dec 1981 00:00:00 (UTC|GMT)$/); + }); + +}); diff --git a/test/ngCookies/cookiesSpec.js b/test/ngCookies/cookiesSpec.js index 0a36c92f2aef..38c030443c3f 100644 --- a/test/ngCookies/cookiesSpec.js +++ b/test/ngCookies/cookiesSpec.js @@ -1,149 +1,142 @@ 'use strict'; describe('$cookies', function() { - beforeEach(module('ngCookies', function($provide) { - $provide.factory('$browser', function() { - return angular.extend(new angular.mock.$Browser(), {cookieHash: {preexisting:'oldCookie'}}); + var mockedCookies; + + beforeEach(function() { + mockedCookies = {}; + module('ngCookies', { + $$cookieWriter: jasmine.createSpy('$$cookieWriter').and.callFake(function(name, value) { + mockedCookies[name] = value; + }), + $$cookieReader: function() { + return mockedCookies; + } }); - })); + }); - it('should provide access to existing cookies via object properties and keep them in sync', - inject(function($cookies, $browser, $rootScope) { - expect($cookies).toEqual({'preexisting': 'oldCookie'}); + it('should serialize objects to json', inject(function($cookies) { + $cookies.putObject('objectCookie', {id: 123, name: 'blah'}); + expect($cookies.get('objectCookie')).toEqual('{"id":123,"name":"blah"}'); + })); - // access internal cookie storage of the browser mock directly to simulate behavior of - // document.cookie - $browser.cookieHash['brandNew'] = 'cookie'; - $browser.poll(); - expect($cookies).toEqual({'preexisting': 'oldCookie', 'brandNew':'cookie'}); + it('should deserialize json to object', inject(function($cookies) { + $cookies.put('objectCookie', '{"id":123,"name":"blah"}'); + expect($cookies.getObject('objectCookie')).toEqual({id: 123, name: 'blah'}); + })); - $browser.cookieHash['brandNew'] = 'cookie2'; - $browser.poll(); - expect($cookies).toEqual({'preexisting': 'oldCookie', 'brandNew':'cookie2'}); - delete $browser.cookieHash['brandNew']; - $browser.poll(); - expect($cookies).toEqual({'preexisting': 'oldCookie'}); + it('should delete objects from the store when remove is called', inject(function($cookies) { + $cookies.putObject('gonner', { 'I\'ll':'Be Back'}); + expect($cookies.get('gonner')).toEqual('{"I\'ll":"Be Back"}'); + $cookies.remove('gonner'); + expect($cookies.get('gonner')).toEqual(undefined); })); - it('should create or update a cookie when a value is assigned to a property', - inject(function($cookies, $browser, $rootScope) { - $cookies.oatmealCookie = 'nom nom'; - $rootScope.$digest(); - - expect($browser.cookies()). - toEqual({'preexisting': 'oldCookie', 'oatmealCookie':'nom nom'}); + it('should handle empty string value cookies', inject(function($cookies) { + $cookies.putObject('emptyCookie',''); + expect($cookies.get('emptyCookie')).toEqual('""'); + expect($cookies.getObject('emptyCookie')).toEqual(''); + mockedCookies['blankCookie'] = ''; + expect($cookies.getObject('blankCookie')).toEqual(''); + })); - $cookies.oatmealCookie = 'gone'; - $rootScope.$digest(); - expect($browser.cookies()). - toEqual({'preexisting': 'oldCookie', 'oatmealCookie': 'gone'}); + it('should put cookie value without serializing', inject(function($cookies) { + $cookies.put('name', 'value'); + $cookies.put('name2', '"value2"'); + expect($cookies.get('name')).toEqual('value'); + expect($cookies.getObject('name2')).toEqual('value2'); })); - it('should convert non-string values to string', - inject(function($cookies, $browser, $rootScope) { - $cookies.nonString = [1, 2, 3]; - $cookies.nullVal = null; - $cookies.undefVal = undefined; - var preexisting = $cookies.preexisting = function() {}; - $rootScope.$digest(); - expect($browser.cookies()).toEqual({ - 'preexisting': '' + preexisting, - 'nonString': '1,2,3', - 'nullVal': 'null', - 'undefVal': 'undefined' - }); - expect($cookies).toEqual({ - 'preexisting': '' + preexisting, - 'nonString': '1,2,3', - 'nullVal': 'null', - 'undefVal': 'undefined' - }); + it('should get cookie value without deserializing', inject(function($cookies) { + $cookies.put('name', 'value'); + $cookies.putObject('name2', 'value2'); + expect($cookies.get('name')).toEqual('value'); + expect($cookies.get('name2')).toEqual('"value2"'); })); + it('should get all the cookies', inject(function($cookies) { + $cookies.put('name', 'value'); + $cookies.putObject('name2', 'value2'); + expect($cookies.getAll()).toEqual({name: 'value', name2: '"value2"'}); + })); - it('should remove a cookie when a $cookies property is deleted', - inject(function($cookies, $browser, $rootScope) { - $cookies.oatmealCookie = 'nom nom'; - $rootScope.$digest(); - $browser.poll(); - expect($browser.cookies()). - toEqual({'preexisting': 'oldCookie', 'oatmealCookie':'nom nom'}); - - delete $cookies.oatmealCookie; - $rootScope.$digest(); - expect($browser.cookies()).toEqual({'preexisting': 'oldCookie'}); + it('should pass options on put', inject(function($cookies, $$cookieWriter) { + $cookies.put('name', 'value', {path: '/a/b'}); + expect($$cookieWriter).toHaveBeenCalledWith('name', 'value', {path: '/a/b'}); })); - it('should drop or reset cookies that browser refused to store', - inject(function($cookies, $browser, $rootScope) { - var i, longVal; + it('should pass options on putObject', inject(function($cookies, $$cookieWriter) { + $cookies.putObject('name', 'value', {path: '/a/b'}); + expect($$cookieWriter).toHaveBeenCalledWith('name', '"value"', {path: '/a/b'}); + })); - for (i = 0; i < 5000; i++) { - longVal += '*'; - } - //drop if no previous value - $cookies.longCookie = longVal; - $rootScope.$digest(); - expect($cookies).toEqual({'preexisting': 'oldCookie'}); + it('should pass options on remove', inject(function($cookies, $$cookieWriter) { + $cookies.remove('name', {path: '/a/b'}); + expect($$cookieWriter).toHaveBeenCalledWith('name', undefined, {path: '/a/b'}); + })); - //reset if previous value existed - $cookies.longCookie = 'shortVal'; - $rootScope.$digest(); - expect($cookies).toEqual({'preexisting': 'oldCookie', 'longCookie': 'shortVal'}); - $cookies.longCookie = longVal; - $rootScope.$digest(); - expect($cookies).toEqual({'preexisting': 'oldCookie', 'longCookie': 'shortVal'}); - })); -}); + it('should pass default options on put', function() { + module(function($cookiesProvider) { + $cookiesProvider.defaults.secure = true; + }); + inject(function($cookies, $$cookieWriter) { + $cookies.put('name', 'value', {path: '/a/b'}); + expect($$cookieWriter).toHaveBeenCalledWith('name', 'value', {path: '/a/b', secure: true}); + }); + }); -describe('$cookieStore', function() { + it('should pass default options on putObject', function() { + module(function($cookiesProvider) { + $cookiesProvider.defaults.secure = true; + }); + inject(function($cookies, $$cookieWriter) { + $cookies.putObject('name', 'value', {path: '/a/b'}); + expect($$cookieWriter).toHaveBeenCalledWith('name', '"value"', {path: '/a/b', secure: true}); + }); + }); - beforeEach(module('ngCookies')); - it('should serialize objects to json', inject(function($cookieStore, $browser, $rootScope) { - $cookieStore.put('objectCookie', {id: 123, name: 'blah'}); - $rootScope.$digest(); - expect($browser.cookies()).toEqual({'objectCookie': '{"id":123,"name":"blah"}'}); - })); + it('should pass default options on remove', function() { + module(function($cookiesProvider) { + $cookiesProvider.defaults.secure = true; + }); + inject(function($cookies, $$cookieWriter) { + $cookies.remove('name', {path: '/a/b'}); + expect($$cookieWriter).toHaveBeenCalledWith('name', undefined, {path: '/a/b', secure: true}); + }); + }); - it('should deserialize json to object', inject(function($cookieStore, $browser) { - $browser.cookies('objectCookie', '{"id":123,"name":"blah"}'); - $browser.poll(); - expect($cookieStore.get('objectCookie')).toEqual({id: 123, name: 'blah'}); - })); + it('should let passed options override default options', function() { + module(function($cookiesProvider) { + $cookiesProvider.defaults.secure = true; + }); + inject(function($cookies, $$cookieWriter) { + $cookies.put('name', 'value', {secure: false}); + expect($$cookieWriter).toHaveBeenCalledWith('name', 'value', {secure: false}); + }); + }); - it('should delete objects from the store when remove is called', inject(function($cookieStore, $browser, $rootScope) { - $cookieStore.put('gonner', { "I'll":"Be Back"}); - $rootScope.$digest(); //force eval in test - $browser.poll(); - expect($browser.cookies()).toEqual({'gonner': '{"I\'ll":"Be Back"}'}); + it('should pass default options if no options are passed', function() { + module(function($cookiesProvider) { + $cookiesProvider.defaults.secure = true; + }); + inject(function($cookies, $$cookieWriter) { + $cookies.put('name', 'value'); + expect($$cookieWriter).toHaveBeenCalledWith('name', 'value', {secure: true}); + }); + }); - $cookieStore.remove('gonner'); - $rootScope.$digest(); - expect($browser.cookies()).toEqual({}); - })); - it('should handle empty string value cookies', inject(function($cookieStore, $browser, $rootScope) { - $cookieStore.put("emptyCookie",''); - $rootScope.$digest(); - expect($browser.cookies()). - toEqual({ 'emptyCookie': '""' }); - expect($cookieStore.get("emptyCookie")).toEqual(''); - - $browser.cookieHash['blankCookie'] = ''; - $browser.poll(); - expect($cookieStore.get("blankCookie")).toEqual(''); - })); -}); + }); diff --git a/test/ngMessageFormat/messageFormatSpec.js b/test/ngMessageFormat/messageFormatSpec.js new file mode 100644 index 000000000000..9ba14c8afa8d --- /dev/null +++ b/test/ngMessageFormat/messageFormatSpec.js @@ -0,0 +1,731 @@ +'use strict'; + +/* TODO: Add tests for: + • Whitespace preservation in messages. + • Whitespace ignored around syntax except for offset:N. + • Escaping for curlies and the # symbol. + • # symbol value. + • # symbol value when gender is nested inside plural. + • Error with nested # symbol. + • parser error messages. + • caching. + • watched expressions. + • test parsing AngularJS expressions + • test the different regexes + • test the different starting rules +*/ + +describe('$$ngMessageFormat', function() { + describe('core', function() { + var $$messageFormat, $parse, $interpolate, $locale, $rootScope; + + function Person(name, gender) { + this.name = name; + this.gender = gender; + } + + var alice = new Person('Alice', 'female'), + bob = new Person('Bob', 'male'), + charlie = new Person('Charlie', 'male'), + harry = new Person('Harry Potter', 'male'); + + function initScope($scope) { + $scope.recipients = [alice, bob, charlie]; + $scope.sender = harry; + } + + beforeEach(module('ngMessageFormat')); + + beforeEach(function() { + inject(['$$messageFormat', '$parse', '$locale', '$interpolate', '$rootScope', function( + messageFormat, parse, locale, interpolate, rootScope) { + $$messageFormat = messageFormat; + $parse = parse; + $interpolate = interpolate; + $locale = locale; + $rootScope = rootScope; + initScope(rootScope); + }]); + }); + + describe('mustache', function() { + function assertMustache(text, expected) { + var parsedFn = $interpolate(text); + expect(parsedFn($rootScope)).toEqual(expected); + } + + it('should suppress falsy objects', function() { + assertMustache('{{undefined}}', ''); + assertMustache('{{null}}', ''); + assertMustache('{{a.b}}', ''); + }); + + it('should jsonify objects', function() { + assertMustache('{{ {} }}', '{}'); + assertMustache('{{ true }}', 'true'); + assertMustache('{{ false }}', 'false'); + assertMustache('{{ 1 }}', '1'); + assertMustache('{{ \'1\' }}', '1'); + assertMustache('{{ sender }}', '{"name":"Harry Potter","gender":"male"}'); + }); + + it('should return function that can be called with no context', inject(function($interpolate) { + expect($interpolate('{{sender.name}}')()).toEqual(''); + })); + + describe('watchable', function() { + it('ckck', function() { + var calls = []; + $rootScope.$watch($interpolate('{{::name}}'), function(val) { + calls.push(val); + }); + + $rootScope.$apply(); + expect(calls.length).toBe(1); + + $rootScope.name = 'foo'; + $rootScope.$apply(); + expect(calls.length).toBe(2); + expect(calls[1]).toBe('foo'); + + $rootScope.name = 'bar'; + $rootScope.$apply(); + expect(calls.length).toBe(2); + }); + + + it('should stop watching strings with no expressions after first execution', function() { + var spy = jasmine.createSpy(); + $rootScope.$watch($$messageFormat.interpolate('foo'), spy); + $rootScope.$digest(); + expect($rootScope.$countWatchers()).toBe(0); + expect(spy).toHaveBeenCalledWith('foo', 'foo', $rootScope); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should stop watching strings with only constant expressions after first execution', function() { + var spy = jasmine.createSpy(); + $rootScope.$watch($$messageFormat.interpolate('foo {{42}}'), spy); + $rootScope.$digest(); + expect($rootScope.$countWatchers()).toBe(0); + expect(spy).toHaveBeenCalledWith('foo 42', 'foo 42', $rootScope); + expect(spy).toHaveBeenCalledTimes(1); + }); + + + }); + + describe('plural', function() { + it('no interpolation', function() { + var text = '' + + '{{recipients.length, plural,\n' + + ' =0 {You gave no gifts}\n' + + ' =1 {You gave one person a gift}\n' + + // "=1" should override "one" for exact value. + ' one {YOU SHOULD NEVER SEE THIS MESSAGE}\n' + + ' other {You gave some people gifts}\n' + + '}}'; + var parsedFn = $interpolate(text, /*mustHaveExpression=*/true); + expect(parsedFn.expressions.length).toBe(1); + expect(parsedFn.expressions[0]).toEqual('recipients.length'); + + $rootScope.recipients.length = 2; + expect(parsedFn($rootScope)).toEqual('You gave some people gifts'); + + $rootScope.recipients.length = 1; + expect(parsedFn($rootScope)).toEqual('You gave one person a gift'); + + $rootScope.recipients.length = 0; + expect(parsedFn($rootScope)).toEqual('You gave no gifts'); + }); + + it('with interpolation', function() { + var text = '' + + '{{recipients.length, plural,\n' + + ' =0 {{{sender.name}} gave no gifts}\n' + + ' =1 {{{sender.name}} gave one gift to {{recipients[0].name}}}\n' + + // "=1" should override "one" for exact value. + ' one {YOU SHOULD NEVER SEE THIS MESSAGE}\n' + + ' other {{{sender.name}} gave them a gift}\n' + + '}}'; + var parsedFn = $interpolate(text, /*mustHaveExpression=*/true); + expect(parsedFn.expressions.length).toBe(1); + expect(parsedFn.expressions[0]).toEqual('recipients.length'); + + $rootScope.recipients.length = 2; + expect(parsedFn($rootScope)).toEqual('Harry Potter gave them a gift'); + + $rootScope.recipients.length = 1; + expect(parsedFn($rootScope)).toEqual('Harry Potter gave one gift to Alice'); + + $rootScope.recipients.length = 0; + expect(parsedFn($rootScope)).toEqual('Harry Potter gave no gifts'); + }); + + it('with offset, interpolation, "#" symbol with and without escaping', function() { + var text = '' + + '{{recipients.length, plural, offset:1\n' + + // NOTE: It's nonsensical to use "#" for "=0" with a positive offset. + ' =0 {{{sender.name}} gave no gifts (\\#=#)}\n' + + ' =1 {{{sender.name}} gave one gift to {{recipients[0].name}} (\\#=#)}\n' + + ' one {{{sender.name}} gave {{recipients[0].name}} and one other person a gift (\\#=#)}\n' + + ' other {{{sender.name}} gave {{recipients[0].name}} and # other people a gift (\\#=#)}\n' + + '}}'; + var parsedFn = $interpolate(text, /*mustHaveExpression=*/true); + expect(parsedFn.expressions.length).toBe(1); + expect(parsedFn.expressions[0]).toEqual('recipients.length'); + + $rootScope.recipients.length = 3; + // "#" should get replaced with the value of "recipients.length - offset" + expect(parsedFn($rootScope)).toEqual('Harry Potter gave Alice and 2 other people a gift (#=2)'); + + $rootScope.recipients.length = 2; + expect(parsedFn($rootScope)).toEqual('Harry Potter gave Alice and one other person a gift (#=1)'); + + $rootScope.recipients.length = 1; + expect(parsedFn($rootScope)).toEqual('Harry Potter gave one gift to Alice (#=0)'); + + $rootScope.recipients.length = 0; + expect(parsedFn($rootScope)).toEqual('Harry Potter gave no gifts (#=-1)'); + }); + }); + + it('nested plural and select', function() { + var text = '' + + '{{recipients.length, plural,\n' + + ' =0 {You gave no gifts}\n' + + ' =1 {{{recipients[0].gender, select,\n' + + ' male {You gave him a gift. -{{sender.name}}}\n' + + ' female {You gave her a gift. -{{sender.name}}}\n' + + ' other {You gave them a gift. -{{sender.name}}}\n' + + ' }}\n' + + ' }\n' + + ' other {You gave {{recipients.length}} people gifts. -{{sender.name}}}\n' + + '}}'; + var parsedFn = $interpolate(text, /*mustHaveExpression=*/true); + expect(parsedFn.expressions.length).toBe(1); + expect(parsedFn.expressions[0]).toEqual('recipients.length'); + var result = parsedFn($rootScope); + expect(result).toEqual('You gave 3 people gifts. -Harry Potter'); + }); + }); + + describe('interpolate', function() { + function assertInterpolation(text, expected) { + var parsedFn = $$messageFormat.interpolate(text); + expect(parsedFn($rootScope)).toEqual(expected); + } + + it('should interpolate a plain string', function() { + assertInterpolation(' Hello, world! ', ' Hello, world! '); + }); + + it('should interpolate a simple expression', function() { + assertInterpolation('Hello, {{sender.name}}!', 'Hello, Harry Potter!'); + }); + }); + }); + + + /* NOTE: This describe block includes a copy of interpolateSpec.js to test that + * $$messageFormat.interpolate behaves the same as $interpolate. + * ONLY the following changes have been made. + * - Add beforeEach(module('ngMessageFormat')) at top level of describe() + * - Add extra "}" for it('should not unescape markers within expressions'). Original + * $interpolate has a bug/feature where a "}}" inside a string is also treated as a + * closing symbol. The new service understands the string context and fixes this. + * - All tests for startSymbol/endSymbol have been commented out. The new service does not + * allow you to change them as of now. + * - Instead, I've added tests to assert that we throw an exception if used with redefined + * startSymbol/endSymbol. These tests are listed right in the beginning before the + * others. allow you to change them as of now. + */ + describe('$interpolate', function() { + beforeEach(module('ngMessageFormat')); + + describe('startSymbol', function() { + it('should expose the startSymbol in run phase', inject(function($interpolate) { + expect($interpolate.startSymbol()).toBe('{{'); + })); + describe('redefinition', function() { + beforeEach(module(function($interpolateProvider) { + expect($interpolateProvider.startSymbol()).toBe('{{'); + $interpolateProvider.startSymbol('(('); + })); + it('should not work when the startSymbol is redefined', function() { + expect(function() { + inject(inject(function($interpolate) {})); + }).toThrowMinErr('$interpolate', 'nochgmustache'); + }); + }); + }); + + describe('endSymbol', function() { + it('should expose the endSymbol in run phase', inject(function($interpolate) { + expect($interpolate.endSymbol()).toBe('}}'); + })); + describe('redefinition', function() { + beforeEach(module(function($interpolateProvider) { + expect($interpolateProvider.endSymbol()).toBe('}}'); + $interpolateProvider.endSymbol('))'); + })); + it('should not work when the endSymbol is redefined', function() { + expect(function() { + inject(inject(function($interpolate) {})); + }).toThrowMinErr('$interpolate', 'nochgmustache'); + }); + }); + }); + + it('should return the interpolation object when there are no bindings and textOnly is undefined', + inject(function($interpolate) { + var interpolateFn = $interpolate('some text'); + + expect(interpolateFn.exp).toBe('some text'); + expect(interpolateFn.expressions).toEqual([]); + + expect(interpolateFn({})).toBe('some text'); + })); + + + it('should return undefined when there are no bindings and textOnly is set to true', + inject(function($interpolate) { + expect($interpolate('some text', true)).toBeUndefined(); + })); + + it('should return undefined when there are bindings and strict is set to true', + inject(function($interpolate) { + expect($interpolate('test {{foo}}', false, null, true)({})).toBeUndefined(); + })); + + it('should suppress falsy objects', inject(function($interpolate) { + expect($interpolate('{{undefined}}')({})).toEqual(''); + expect($interpolate('{{null}}')({})).toEqual(''); + expect($interpolate('{{a.b}}')({})).toEqual(''); + })); + + it('should jsonify objects', inject(function($interpolate) { + expect($interpolate('{{ {} }}')({})).toEqual('{}'); + expect($interpolate('{{ true }}')({})).toEqual('true'); + expect($interpolate('{{ false }}')({})).toEqual('false'); + })); + + + it('should use custom toString when present', inject(function($interpolate, $rootScope) { + var context = { + a: { + toString: function() { + return 'foo'; + } + } + }; + + expect($interpolate('{{ a }}')(context)).toEqual('foo'); + })); + + it('should NOT use toString on array objects', inject(function($interpolate) { + expect($interpolate('{{a}}')({ a: [] })).toEqual('[]'); + })); + + + it('should NOT use toString on Date objects', inject(function($interpolate) { + var date = new Date(2014, 10, 10); + expect($interpolate('{{a}}')({ a: date })).toBe(JSON.stringify(date)); + expect($interpolate('{{a}}')({ a: date })).not.toEqual(date.toString()); + })); + + + it('should return interpolation function', inject(function($interpolate, $rootScope) { + var interpolateFn = $interpolate('Hello {{name}}!'); + + expect(interpolateFn.exp).toBe('Hello {{name}}!'); + expect(interpolateFn.expressions).toEqual(['name']); + + var scope = $rootScope.$new(); + scope.name = 'Bubu'; + + expect(interpolateFn(scope)).toBe('Hello Bubu!'); + })); + + + it('should ignore undefined model', inject(function($interpolate) { + expect($interpolate('Hello {{\'World\'}}{{foo}}')({})).toBe('Hello World'); + })); + + + it('should interpolate with undefined context', inject(function($interpolate) { + expect($interpolate('Hello, world!{{bloop}}')()).toBe('Hello, world!'); + })); + + describe('watching', function() { + it('should be watchable with any input types', inject(function($interpolate, $rootScope) { + var lastVal; + $rootScope.$watch($interpolate('{{i}}'), function(val) { + lastVal = val; + }); + $rootScope.$apply(); + expect(lastVal).toBe(''); + + $rootScope.i = null; + $rootScope.$apply(); + expect(lastVal).toBe(''); + + $rootScope.i = ''; + $rootScope.$apply(); + expect(lastVal).toBe(''); + + $rootScope.i = 0; + $rootScope.$apply(); + expect(lastVal).toBe('0'); + + $rootScope.i = [0]; + $rootScope.$apply(); + expect(lastVal).toBe('[0]'); + + $rootScope.i = {a: 1, b: 2}; + $rootScope.$apply(); + expect(lastVal).toBe('{"a":1,"b":2}'); + })); + + it('should be watchable with literal values', inject(function($interpolate, $rootScope) { + var lastVal; + $rootScope.$watch($interpolate('{{1}}{{"2"}}{{true}}{{[false]}}{{ {a: 2} }}'), function(val) { + lastVal = val; + }); + $rootScope.$apply(); + expect(lastVal).toBe('12true[false]{"a":2}'); + + expect($rootScope.$countWatchers()).toBe(0); + })); + + it('should respect one-time bindings for each individual expression', inject(function($interpolate, $rootScope) { + var calls = []; + $rootScope.$watch($interpolate('{{::a | limitTo:1}} {{::s}} {{::i | number}}'), function(val) { + calls.push(val); + }); + + $rootScope.$apply(); + expect(calls.length).toBe(1); + + $rootScope.a = [1]; + $rootScope.$apply(); + expect(calls.length).toBe(2); + expect(calls[1]).toBe('[1] '); + + $rootScope.a = [0]; + $rootScope.$apply(); + expect(calls.length).toBe(2); + + $rootScope.i = $rootScope.a = 123; + $rootScope.s = 'str!'; + $rootScope.$apply(); + expect(calls.length).toBe(3); + expect(calls[2]).toBe('[1] str! 123'); + + expect($rootScope.$countWatchers()).toBe(0); + })); + + it('should stop watching strings with no expressions after first execution', + inject(function($interpolate, $rootScope) { + var spy = jasmine.createSpy(); + $rootScope.$watch($interpolate('foo'), spy); + $rootScope.$digest(); + expect($rootScope.$countWatchers()).toBe(0); + expect(spy).toHaveBeenCalledWith('foo', 'foo', $rootScope); + expect(spy).toHaveBeenCalledTimes(1); + }) + ); + + it('should stop watching strings with only constant expressions after first execution', + inject(function($interpolate, $rootScope) { + var spy = jasmine.createSpy(); + $rootScope.$watch($interpolate('foo {{42}}'), spy); + $rootScope.$digest(); + expect($rootScope.$countWatchers()).toBe(0); + expect(spy).toHaveBeenCalledWith('foo 42', 'foo 42', $rootScope); + expect(spy).toHaveBeenCalledTimes(1); + }) + ); + }); + + describe('interpolation escaping', function() { + var obj; + beforeEach(function() { + obj = {foo: 'Hello', bar: 'World'}; + }); + + + it('should support escaping interpolation signs', inject(function($interpolate) { + expect($interpolate('{{foo}} \\{\\{bar\\}\\}')(obj)).toBe('Hello {{bar}}'); + expect($interpolate('\\{\\{foo\\}\\} {{bar}}')(obj)).toBe('{{foo}} World'); + })); + + + it('should unescape multiple expressions', inject(function($interpolate) { + expect($interpolate('\\{\\{foo\\}\\}\\{\\{bar\\}\\} {{foo}}')(obj)).toBe('{{foo}}{{bar}} Hello'); + expect($interpolate('{{foo}}\\{\\{foo\\}\\}\\{\\{bar\\}\\}')(obj)).toBe('Hello{{foo}}{{bar}}'); + expect($interpolate('\\{\\{foo\\}\\}{{foo}}\\{\\{bar\\}\\}')(obj)).toBe('{{foo}}Hello{{bar}}'); + expect($interpolate('{{foo}}\\{\\{foo\\}\\}{{bar}}\\{\\{bar\\}\\}{{foo}}')(obj)).toBe('Hello{{foo}}World{{bar}}Hello'); + })); + + + /* + *it('should support escaping custom interpolation start/end symbols', function() { + * module(function($interpolateProvider) { + * $interpolateProvider.startSymbol('[['); + * $interpolateProvider.endSymbol(']]'); + * }); + * inject(function($interpolate) { + * expect($interpolate('[[foo]] \\[\\[bar\\]\\]')(obj)).toBe('Hello [[bar]]'); + * }); + *}); + */ + + + it('should unescape incomplete escaped expressions', inject(function($interpolate) { + expect($interpolate('\\{\\{foo{{foo}}')(obj)).toBe('{{fooHello'); + expect($interpolate('\\}\\}foo{{foo}}')(obj)).toBe('}}fooHello'); + expect($interpolate('foo{{foo}}\\{\\{')(obj)).toBe('fooHello{{'); + expect($interpolate('foo{{foo}}\\}\\}')(obj)).toBe('fooHello}}'); + })); + + + it('should not unescape markers within expressions', inject(function($interpolate) { + expect($interpolate('{{"\\\\{\\\\{Hello, world!\\\\}\\\\}"}}')(obj)).toBe('\\{\\{Hello, world!\\}\\}'); + expect($interpolate('{{"\\{\\{Hello, world!\\}\\}"}}')(obj)).toBe('{{Hello, world!}}'); + expect(function() { + $interpolate('{{\\{\\{foo\\}\\}}}')(obj); + }).toThrowMinErr('$parse', 'lexerr', + 'Lexer Error: Unexpected next character at columns 0-0 [\\] in expression [\\{\\{foo\\}\\}]'); + })); + + + // This test demonstrates that the web-server is responsible for escaping every single instance + // of interpolation start/end markers in an expression which they do not wish to evaluate, + // because AngularJS will not protect them from being evaluated (due to the added complexity + // and maintenance burden of context-sensitive escaping) + it('should evaluate expressions between escaped start/end symbols', inject(function($interpolate) { + expect($interpolate('\\{\\{Hello, {{bar}}!\\}\\}')(obj)).toBe('{{Hello, World!}}'); + })); + }); + + + describe('interpolating in a trusted context', function() { + var sce; + beforeEach(function() { + function log() {} + var fakeLog = {log: log, warn: log, info: log, error: log}; + module(function($provide, $sceProvider) { + $provide.value('$log', fakeLog); + $sceProvider.enabled(true); + }); + inject(['$sce', function($sce) { sce = $sce; }]); + }); + + it('should NOT interpolate non-trusted expressions', inject(function($interpolate, $rootScope) { + var scope = $rootScope.$new(); + scope.foo = 'foo'; + + expect(function() { + $interpolate('{{foo}}', true, sce.CSS)(scope); + }).toThrowMinErr('$interpolate', 'interr'); + })); + + it('should NOT interpolate mistyped expressions', inject(function($interpolate, $rootScope) { + var scope = $rootScope.$new(); + scope.foo = sce.trustAsCss('foo'); + + expect(function() { + $interpolate('{{foo}}', true, sce.HTML)(scope); + }).toThrowMinErr('$interpolate', 'interr'); + })); + + it('should interpolate trusted expressions in a regular context', inject(function($interpolate) { + var foo = sce.trustAsCss('foo'); + expect($interpolate('{{foo}}', true)({foo: foo})).toBe('foo'); + })); + + it('should interpolate trusted expressions in a specific trustedContext', inject(function($interpolate) { + var foo = sce.trustAsCss('foo'); + expect($interpolate('{{foo}}', true, sce.CSS)({foo: foo})).toBe('foo'); + })); + + // The concatenation of trusted values does not necessarily result in a trusted value. (For + // instance, you can construct evil JS code by putting together pieces of JS strings that are by + // themselves safe to execute in isolation.) + it('should NOT interpolate trusted expressions with multiple parts', inject(function($interpolate) { + var foo = sce.trustAsCss('foo'); + var bar = sce.trustAsCss('bar'); + expect(function() { + return $interpolate('{{foo}}{{bar}}', true, sce.CSS)({foo: foo, bar: bar}); + }).toThrowMinErr( + '$interpolate', 'noconcat', 'Error while interpolating: {{foo}}{{bar}}\n' + + 'Strict Contextual Escaping disallows interpolations that concatenate multiple ' + + 'expressions when a trusted value is required. See ' + + 'http://docs.angularjs.org/api/ng.$sce'); + })); + }); + + +/* + * describe('provider', function() { + * beforeEach(module(function($interpolateProvider) { + * $interpolateProvider.startSymbol('--'); + * $interpolateProvider.endSymbol('--'); + * })); + * + * it('should not get confused with same markers', inject(function($interpolate) { + * expect($interpolate('---').expressions).toEqual([]); + * expect($interpolate('----')({})).toEqual(''); + * expect($interpolate('--1--')({})).toEqual('1'); + * })); + * }); + */ + + describe('parseBindings', function() { + it('should Parse Text With No Bindings', inject(function($interpolate) { + expect($interpolate('a').expressions).toEqual([]); + })); + + it('should Parse Empty Text', inject(function($interpolate) { + expect($interpolate('').expressions).toEqual([]); + })); + + it('should Parse Inner Binding', inject(function($interpolate) { + var interpolateFn = $interpolate('a{{b}}C'), + expressions = interpolateFn.expressions; + expect(expressions).toEqual(['b']); + expect(interpolateFn({b: 123})).toEqual('a123C'); + })); + + it('should Parse Ending Binding', inject(function($interpolate) { + var interpolateFn = $interpolate('a{{b}}'), + expressions = interpolateFn.expressions; + expect(expressions).toEqual(['b']); + expect(interpolateFn({b: 123})).toEqual('a123'); + })); + + it('should Parse Begging Binding', inject(function($interpolate) { + var interpolateFn = $interpolate('{{b}}c'), + expressions = interpolateFn.expressions; + expect(expressions).toEqual(['b']); + expect(interpolateFn({b: 123})).toEqual('123c'); + })); + + it('should Parse Loan Binding', inject(function($interpolate) { + var interpolateFn = $interpolate('{{b}}'), + expressions = interpolateFn.expressions; + expect(expressions).toEqual(['b']); + expect(interpolateFn({b: 123})).toEqual('123'); + })); + + it('should Parse Two Bindings', inject(function($interpolate) { + var interpolateFn = $interpolate('{{b}}{{c}}'), + expressions = interpolateFn.expressions; + expect(expressions).toEqual(['b', 'c']); + expect(interpolateFn({b: 111, c: 222})).toEqual('111222'); + })); + + it('should Parse Two Bindings With Text In Middle', inject(function($interpolate) { + var interpolateFn = $interpolate('{{b}}x{{c}}'), + expressions = interpolateFn.expressions; + expect(expressions).toEqual(['b', 'c']); + expect(interpolateFn({b: 111, c: 222})).toEqual('111x222'); + })); + + it('should Parse Multiline', inject(function($interpolate) { + var interpolateFn = $interpolate('"X\nY{{A\n+B}}C\nD"'), + expressions = interpolateFn.expressions; + expect(expressions).toEqual(['A\n+B']); + expect(interpolateFn({'A': 'aa', 'B': 'bb'})).toEqual('"X\nYaabbC\nD"'); + })); + }); + + + describe('isTrustedContext', function() { + it('should NOT interpolate a multi-part expression when isTrustedContext is true', inject(function($interpolate) { + var isTrustedContext = true; + expect(function() { + $interpolate('constant/{{var}}', true, isTrustedContext); + }).toThrowMinErr( + '$interpolate', 'noconcat', 'Error while interpolating: constant/{{var}}\nStrict ' + + 'Contextual Escaping disallows interpolations that concatenate multiple expressions ' + + 'when a trusted value is required. See http://docs.angularjs.org/api/ng.$sce'); + expect(function() { + $interpolate('{{var}}/constant', true, isTrustedContext); + }).toThrowMinErr( + '$interpolate', 'noconcat', 'Error while interpolating: {{var}}/constant\nStrict ' + + 'Contextual Escaping disallows interpolations that concatenate multiple expressions ' + + 'when a trusted value is required. See http://docs.angularjs.org/api/ng.$sce'); + expect(function() { + $interpolate('{{foo}}{{bar}}', true, isTrustedContext); + }).toThrowMinErr( + '$interpolate', 'noconcat', 'Error while interpolating: {{foo}}{{bar}}\nStrict ' + + 'Contextual Escaping disallows interpolations that concatenate multiple expressions ' + + 'when a trusted value is required. See http://docs.angularjs.org/api/ng.$sce'); + })); + + it('should interpolate a multi-part expression when isTrustedContext is false', inject(function($interpolate) { + expect($interpolate('some/{{id}}')({})).toEqual('some/'); + expect($interpolate('some/{{id}}')({id: 1})).toEqual('some/1'); + expect($interpolate('{{foo}}{{bar}}')({foo: 1, bar: 2})).toEqual('12'); + })); + }); + +/* + * describe('startSymbol', function() { + * + * beforeEach(module(function($interpolateProvider) { + * expect($interpolateProvider.startSymbol()).toBe('{{'); + * $interpolateProvider.startSymbol('(('); + * })); + * + * + * it('should expose the startSymbol in config phase', module(function($interpolateProvider) { + * expect($interpolateProvider.startSymbol()).toBe('(('); + * })); + * + * + * it('should expose the startSymbol in run phase', inject(function($interpolate) { + * expect($interpolate.startSymbol()).toBe('(('); + * })); + * + * + * it('should not get confused by matching start and end symbols', function() { + * module(function($interpolateProvider) { + * $interpolateProvider.startSymbol('--'); + * $interpolateProvider.endSymbol('--'); + * }); + * + * inject(function($interpolate) { + * expect($interpolate('---').expressions).toEqual([]); + * expect($interpolate('----')({})).toEqual(''); + * expect($interpolate('--1--')({})).toEqual('1'); + * }); + * }); + * }); + */ + + +/* + * describe('endSymbol', function() { + * + * beforeEach(module(function($interpolateProvider) { + * expect($interpolateProvider.endSymbol()).toBe('}}'); + * $interpolateProvider.endSymbol('))'); + * })); + * + * + * it('should expose the endSymbol in config phase', module(function($interpolateProvider) { + * expect($interpolateProvider.endSymbol()).toBe('))'); + * })); + * + * + * it('should expose the endSymbol in run phase', inject(function($interpolate) { + * expect($interpolate.endSymbol()).toBe('))'); + * })); + * }); + */ + + }); // end of tests copied from $interpolate +}); diff --git a/test/ngMessages/messagesSpec.js b/test/ngMessages/messagesSpec.js index a678b406e609..527a577b1f18 100644 --- a/test/ngMessages/messagesSpec.js +++ b/test/ngMessages/messagesSpec.js @@ -8,23 +8,14 @@ describe('ngMessages', function() { return (element.length ? element[0] : element).querySelectorAll('[ng-message], [ng-message-exp]'); } - function they(msg, vals, spec, focus) { - forEach(vals, function(val, key) { - var m = msg.replace('$prop', key); - (focus ? iit : it)(m, function() { - spec(val); - }); - }); - } - - function tthey(msg, vals, spec) { - they(msg, vals, spec, true); - } - function s(str) { return str.replace(/\s+/g,''); } + function trim(value) { + return isString(value) ? value.trim() : value; + } + var element; afterEach(function() { dealoc(element); @@ -331,7 +322,7 @@ describe('ngMessages', function() { expect(element.hasClass('ng-inactive')).toBe(false); })); - it('should automatically re-render the messages when other directives dynmically change them', + it('should automatically re-render the messages when other directives dynamically change them', inject(function($rootScope, $compile) { element = $compile('
          ' + @@ -351,21 +342,21 @@ describe('ngMessages', function() { }); expect(messageChildren(element).length).toBe(0); - expect(trim(element.text())).toEqual(""); + expect(trim(element.text())).toEqual(''); $rootScope.$apply(function() { $rootScope.col = { hair: true }; }); expect(messageChildren(element).length).toBe(1); - expect(trim(element.text())).toEqual("Your hair is too long"); + expect(trim(element.text())).toEqual('Your hair is too long'); $rootScope.$apply(function() { $rootScope.col = { age: true, hair: true}; }); expect(messageChildren(element).length).toBe(1); - expect(trim(element.text())).toEqual("Your age is incorrect"); + expect(trim(element.text())).toEqual('Your age is incorrect'); $rootScope.$apply(function() { // remove the age! @@ -373,7 +364,7 @@ describe('ngMessages', function() { }); expect(messageChildren(element).length).toBe(1); - expect(trim(element.text())).toEqual("Your hair is too long"); + expect(trim(element.text())).toEqual('Your hair is too long'); $rootScope.$apply(function() { // remove the hair! @@ -382,10 +373,94 @@ describe('ngMessages', function() { }); expect(messageChildren(element).length).toBe(1); - expect(trim(element.text())).toEqual("Enter something"); + expect(trim(element.text())).toEqual('Enter something'); })); + it('should be compatible with ngBind', + inject(function($rootScope, $compile) { + + element = $compile('
          ' + + '
          ' + + '
          ' + + '
          ')($rootScope); + + $rootScope.$apply(function() { + $rootScope.col = { + required: true, + extra: true + }; + $rootScope.errorMessages = { + required: 'Fill in the text field.', + extra: 'Extra error message.' + }; + }); + + expect(messageChildren(element).length).toBe(1); + expect(trim(element.text())).toEqual('Fill in the text field.'); + + $rootScope.$apply(function() { + $rootScope.col.required = false; + $rootScope.col.extra = true; + }); + + expect(messageChildren(element).length).toBe(1); + expect(trim(element.text())).toEqual('Extra error message.'); + + $rootScope.$apply(function() { + $rootScope.errorMessages.extra = 'New error message.'; + }); + + expect(messageChildren(element).length).toBe(1); + expect(trim(element.text())).toEqual('New error message.'); + })); + + + // issue #12856 + it('should only detach the message object that is associated with the message node being removed', + inject(function($rootScope, $compile, $animate) { + + // We are going to spy on the `leave` method to give us control over + // when the element is actually removed + spyOn($animate, 'leave'); + + // Create a basic ng-messages set up + element = $compile('
          ' + + '
          Enter something
          ' + + '
          ')($rootScope); + + // Trigger the message to be displayed + $rootScope.col = { primary: true }; + $rootScope.$digest(); + expect(messageChildren(element).length).toEqual(1); + var oldMessageNode = messageChildren(element)[0]; + + // Remove the message + $rootScope.col = { primary: undefined }; + $rootScope.$digest(); + + // Since we have spied on the `leave` method, the message node is still in the DOM + expect($animate.leave).toHaveBeenCalledOnce(); + var nodeToRemove = $animate.leave.calls.mostRecent().args[0][0]; + expect(nodeToRemove).toBe(oldMessageNode); + $animate.leave.calls.reset(); + + // Add the message back in + $rootScope.col = { primary: true }; + $rootScope.$digest(); + + // Simulate the animation completing on the node + jqLite(nodeToRemove).remove(); + + // We should not get another call to `leave` + expect($animate.leave).not.toHaveBeenCalled(); + + // There should only be the new message node + expect(messageChildren(element).length).toEqual(1); + var newMessageNode = messageChildren(element)[0]; + expect(newMessageNode).not.toBe(oldMessageNode); + })); + it('should render animations when the active/inactive classes are added/removed', function() { module('ngAnimate'); module('ngAnimateMock'); @@ -414,6 +489,272 @@ describe('ngMessages', function() { }); }); + describe('ngMessage nested nested inside elements', function() { + + it('should not crash or leak memory when the messages are transcluded, the first message is ' + + 'visible, and ngMessages is removed by ngIf', function() { + + module(function($compileProvider) { + $compileProvider.directive('messageWrap', function() { + return { + transclude: true, + scope: { + col: '=col' + }, + template: '
          ' + }; + }); + }); + + inject(function($rootScope, $compile) { + + element = $compile('
          ' + + '
          A
          ' + + '
          B
          ' + + '
          ')($rootScope); + + $rootScope.$apply(function() { + $rootScope.show = true; + $rootScope.col = { + a: true, + b: true + }; + }); + + expect(messageChildren(element).length).toBe(1); + expect(trim(element.text())).toEqual('A'); + + $rootScope.$apply('show = false'); + + expect(messageChildren(element).length).toBe(0); + }); + }); + + + it('should not crash when the first of two nested messages is removed', function() { + inject(function($rootScope, $compile) { + + element = $compile( + '
          ' + + '
          ' + + '
          A
          ' + + '
          B
          ' + + '
          ' + + '
          ' + )($rootScope); + + $rootScope.$apply(function() { + $rootScope.col = { + a: true, + b: false + }; + }); + + expect(messageChildren(element).length).toBe(1); + expect(trim(element.text())).toEqual('A'); + + var ctrl = element.controller('ngMessages'); + var deregisterSpy = spyOn(ctrl, 'deregister').and.callThrough(); + + var nodeA = element[0].querySelector('[ng-message="a"]'); + jqLite(nodeA).remove(); + $rootScope.$digest(); // The next digest triggers the error + + // Make sure removing the element triggers the deregistration in ngMessages + expect(trim(deregisterSpy.calls.mostRecent().args[0].nodeValue)).toBe('ngMessage: a'); + expect(messageChildren(element).length).toBe(0); + }); + }); + + + it('should not crash, but show deeply nested messages correctly after a message ' + + 'has been removed', function() { + inject(function($rootScope, $compile) { + + element = $compile( + '
          ' + + '
          ' + + '
          A
          ' + + '
          ' + + '
          B
          ' + + '
          C
          ' + + '
          ' + + '
          D
          ' + + '
          ' + + '
          ' + )($rootScope); + + $rootScope.$apply(function() { + $rootScope.col = { + a: true, + b: true + }; + }); + + expect(messageChildren(element).length).toBe(2); + expect(trim(element.text())).toEqual('AB'); + + var ctrl = element.controller('ngMessages'); + var deregisterSpy = spyOn(ctrl, 'deregister').and.callThrough(); + + var nodeB = element[0].querySelector('[ng-message="b"]'); + jqLite(nodeB).remove(); + $rootScope.$digest(); // The next digest triggers the error + + // Make sure removing the element triggers the deregistration in ngMessages + expect(trim(deregisterSpy.calls.mostRecent().args[0].nodeValue)).toBe('ngMessage: b'); + expect(messageChildren(element).length).toBe(1); + expect(trim(element.text())).toEqual('A'); + }); + }); + }); + + + it('should clean-up the ngMessage scope when a message is removed', + inject(function($compile, $rootScope) { + + var html = + '
          ' + + '
          {{forA}}
          ' + + '
          '; + + element = $compile(html)($rootScope); + $rootScope.$apply(function() { + $rootScope.forA = 'A'; + $rootScope.items = {a: true}; + }); + + expect(element.text()).toBe('A'); + var watchers = $rootScope.$countWatchers(); + + $rootScope.$apply('items.a = false'); + + expect(element.text()).toBe(''); + // We don't know exactly how many watchers are on the scope, only that there should be + // one less now + expect($rootScope.$countWatchers()).toBe(watchers - 1); + }) + ); + + it('should unregister the ngMessage even if it was never attached', + inject(function($compile, $rootScope) { + var html = + '
          ' + + '
          ERROR
          ' + + '
          '; + + element = $compile(html)($rootScope); + + var ctrl = element.controller('ngMessages'); + + expect(messageChildren(element).length).toBe(0); + expect(Object.keys(ctrl.messages).length).toEqual(0); + + $rootScope.$apply('show = true'); + expect(messageChildren(element).length).toBe(0); + expect(Object.keys(ctrl.messages).length).toEqual(1); + + $rootScope.$apply('show = false'); + expect(messageChildren(element).length).toBe(0); + expect(Object.keys(ctrl.messages).length).toEqual(0); + }) + ); + + + describe('default message', function() { + it('should render a default message when no message matches', inject(function($rootScope, $compile) { + element = $compile('
          ' + + '
          Message is set
          ' + + '
          Default message is set
          ' + + '
          ')($rootScope); + $rootScope.$apply(function() { + $rootScope.col = { unexpected: false }; + }); + + $rootScope.$digest(); + + expect(element.text().trim()).toBe(''); + expect(element).not.toHaveClass('ng-active'); + + $rootScope.$apply(function() { + $rootScope.col = { unexpected: true }; + }); + + expect(element.text().trim()).toBe('Default message is set'); + expect(element).toHaveClass('ng-active'); + + $rootScope.$apply(function() { + $rootScope.col = { unexpected: false }; + }); + + expect(element.text().trim()).toBe(''); + expect(element).not.toHaveClass('ng-active'); + + $rootScope.$apply(function() { + $rootScope.col = { val: true, unexpected: true }; + }); + + expect(element.text().trim()).toBe('Message is set'); + expect(element).toHaveClass('ng-active'); + })); + + it('should not render a default message with ng-messages-multiple if another error matches', + inject(function($rootScope, $compile) { + element = $compile('
          ' + + '
          Message is set
          ' + + '
          Other message is set
          ' + + '
          Default message is set
          ' + + '
          ')($rootScope); + + expect(element.text().trim()).toBe(''); + + $rootScope.$apply(function() { + $rootScope.col = { val: true, other: false, unexpected: false }; + }); + + expect(element.text().trim()).toBe('Message is set'); + + $rootScope.$apply(function() { + $rootScope.col = { val: true, other: true, unexpected: true }; + }); + + expect(element.text().trim()).toBe('Message is set Other message is set'); + + $rootScope.$apply(function() { + $rootScope.col = { val: false, other: false, unexpected: true }; + }); + + expect(element.text().trim()).toBe('Default message is set'); + }) + ); + + it('should handle a default message with ngIf', inject(function($rootScope, $compile) { + element = $compile('
          ' + + '
          Message is set
          ' + + '
          Default message is set
          ' + + '
          ')($rootScope); + $rootScope.default = true; + $rootScope.col = {unexpected: true}; + $rootScope.$digest(); + + expect(element.text().trim()).toBe('Default message is set'); + + $rootScope.$apply('default = false'); + + expect(element.text().trim()).toBe(''); + + $rootScope.$apply('default = true'); + + expect(element.text().trim()).toBe('Default message is set'); + + $rootScope.$apply(function() { + $rootScope.col = { val: true }; + }); + + expect(element.text().trim()).toBe('Message is set'); + })); + }); + describe('when including templates', function() { they('should work with a dynamic collection model which is managed by ngRepeat', {'
          ': '
          ' + @@ -440,7 +781,7 @@ describe('ngMessages', function() { var elements = element[0].querySelectorAll('[ng-repeat]'); - // all three collections should have atleast one error showing up + // all three collections should have at least one error showing up expect(messageChildren(element).length).toBe(3); expect(messageChildren(elements[0]).length).toBe(1); expect(messageChildren(elements[1]).length).toBe(1); @@ -520,7 +861,7 @@ describe('ngMessages', function() { }); expect(messageChildren(element).length).toBe(1); - expect(trim(element.text())).toEqual("A"); + expect(trim(element.text())).toEqual('A'); $rootScope.$apply(function() { $rootScope.data = { @@ -529,7 +870,7 @@ describe('ngMessages', function() { }); expect(messageChildren(element).length).toBe(1); - expect(trim(element.text())).toEqual("C"); + expect(trim(element.text())).toEqual('C'); }); }); @@ -567,13 +908,13 @@ describe('ngMessages', function() { $rootScope.$digest(); expect(messageChildren(element).length).toBe(1); - expect(trim(element.text())).toEqual("Your value is that of failure"); + expect(trim(element.text())).toEqual('Your value is that of failure'); $httpBackend.flush(); $rootScope.$digest(); expect(messageChildren(element).length).toBe(1); - expect(trim(element.text())).toEqual("You did not enter a value"); + expect(trim(element.text())).toEqual('You did not enter a value'); })); it('should allow for overriding the remote template messages within the element depending on where the remote template is placed', @@ -598,7 +939,7 @@ describe('ngMessages', function() { }); expect(messageChildren(element).length).toBe(1); - expect(trim(element.text())).toEqual("AAA"); + expect(trim(element.text())).toEqual('AAA'); $rootScope.$apply(function() { $rootScope.data = { @@ -608,7 +949,7 @@ describe('ngMessages', function() { }); expect(messageChildren(element).length).toBe(1); - expect(trim(element.text())).toEqual("B"); + expect(trim(element.text())).toEqual('B'); $rootScope.$apply(function() { $rootScope.data = { @@ -617,9 +958,77 @@ describe('ngMessages', function() { }); expect(messageChildren(element).length).toBe(1); - expect(trim(element.text())).toEqual("C"); + expect(trim(element.text())).toEqual('C'); + })); + + it('should properly detect a previous message, even if it was registered later', + inject(function($compile, $rootScope, $templateCache) { + $templateCache.put('include.html', '
          A
          '); + var html = + '
          ' + + '
          ' + + '
          B
          ' + + '
          C
          ' + + '
          '; + + element = $compile(html)($rootScope); + $rootScope.$apply('items = {b: true, c: true}'); + + expect(element.text()).toBe('B'); + + var ctrl = element.controller('ngMessages'); + var deregisterSpy = spyOn(ctrl, 'deregister').and.callThrough(); + + var nodeB = element[0].querySelector('[ng-message="b"]'); + jqLite(nodeB).remove(); + + // Make sure removing the element triggers the deregistration in ngMessages + expect(trim(deregisterSpy.calls.mostRecent().args[0].nodeValue)).toBe('ngMessage: b'); + + $rootScope.$apply('items.a = true'); + + expect(element.text()).toBe('A'); + }) + ); + + it('should not throw if scope has been destroyed when template request is ready', + inject(function($rootScope, $httpBackend, $compile) { + $httpBackend.expectGET('messages.html').respond('
          A
          '); + $rootScope.show = true; + var html = + '
          ' + + '
          ' + + '
          ' + + '
          ' + + '
          '; + + element = $compile(html)($rootScope); + $rootScope.$digest(); + $rootScope.show = false; + $rootScope.$digest(); + expect(function() { + $httpBackend.flush(); + }).not.toThrow(); })); + it('should not throw if the template is empty', + inject(function($compile, $rootScope, $templateCache) { + var html = + '
          ' + + '
          ' + + '
          ' + + '
          '; + + $templateCache.put('messages1.html', ''); + $templateCache.put('messages2.html', ' '); + element = $compile(html)($rootScope); + $rootScope.$digest(); + + expect(element.text()).toBe(''); + expect(element.children().length).toBe(0); + expect(element.contents().length).toBe(2); + }) + ); }); describe('when multiple', function() { @@ -643,7 +1052,7 @@ describe('ngMessages', function() { }); expect(messageChildren(element).length).toBe(2); - expect(s(element.text())).toContain("13"); + expect(s(element.text())).toContain('13'); }); }); @@ -667,14 +1076,14 @@ describe('ngMessages', function() { }); expect(messageChildren(element).length).toBe(2); - expect(s(element.text())).toEqual("XZ"); + expect(s(element.text())).toEqual('XZ'); $rootScope.$apply(function() { $rootScope.data.y = {}; }); expect(messageChildren(element).length).toBe(3); - expect(s(element.text())).toEqual("XYZ"); + expect(s(element.text())).toEqual('XYZ'); })); it('should render and override all truthy messages from a remote template', @@ -699,14 +1108,14 @@ describe('ngMessages', function() { }); expect(messageChildren(element).length).toBe(2); - expect(s(element.text())).toEqual("ZZZX"); + expect(s(element.text())).toEqual('ZZZX'); $rootScope.$apply(function() { $rootScope.data.y = {}; }); expect(messageChildren(element).length).toBe(3); - expect(s(element.text())).toEqual("YYYZZZX"); + expect(s(element.text())).toEqual('YYYZZZX'); })); }); }); diff --git a/test/ngMock/angular-mocksSpec.js b/test/ngMock/angular-mocksSpec.js index 104ece1c0aa5..f8777c517a70 100644 --- a/test/ngMock/angular-mocksSpec.js +++ b/test/ngMock/angular-mocksSpec.js @@ -1,7 +1,9 @@ 'use strict'; describe('ngMock', function() { + var noop = angular.noop; + var extend = angular.extend; describe('TzDate', function() { @@ -26,17 +28,19 @@ describe('ngMock', function() { it('should fake getLocalDateString method', function() { - //0 in -3h - var t0 = new angular.mock.TzDate(-3, 0); - expect(t0.toLocaleDateString()).toMatch('1970'); + var millennium = new Date('2000').getTime(); - //0 in +0h - var t1 = new angular.mock.TzDate(0, 0); - expect(t1.toLocaleDateString()).toMatch('1970'); + // millennium in -3h + var t0 = new angular.mock.TzDate(-3, millennium); + expect(t0.toLocaleDateString()).toMatch('2000'); - //0 in +3h - var t2 = new angular.mock.TzDate(3, 0); - expect(t2.toLocaleDateString()).toMatch('1969'); + // millennium in +0h + var t1 = new angular.mock.TzDate(0, millennium); + expect(t1.toLocaleDateString()).toMatch('2000'); + + // millennium in +3h + var t2 = new angular.mock.TzDate(3, millennium); + expect(t2.toLocaleDateString()).toMatch('1999'); }); @@ -65,7 +69,7 @@ describe('ngMock', function() { //0:00 in +3h var t2 = new angular.mock.TzDate(3, jan2); - expect(t2.getHours()).toMatch(21); + expect(t2.getHours()).toMatch('21'); }); @@ -88,11 +92,11 @@ describe('ngMock', function() { //0:15 in +3h var t2 = new angular.mock.TzDate(3, minutes(15)); - expect(t2.getMinutes()).toMatch(15); + expect(t2.getMinutes()).toMatch('15'); //0:15 in +3.25h var t2a = new angular.mock.TzDate(3.25, minutes(15)); - expect(t2a.getMinutes()).toMatch(0); + expect(t2a.getMinutes()).toMatch('0'); }); @@ -107,7 +111,7 @@ describe('ngMock', function() { //0 in +3h var t2 = new angular.mock.TzDate(3, 0); - expect(t2.getSeconds()).toMatch(0); + expect(t2.getSeconds()).toMatch('0'); }); @@ -154,7 +158,7 @@ describe('ngMock', function() { it('should throw error when no third param but toString called', function() { expect(function() { new angular.mock.TzDate(0,0).toString(); }). - toThrow('Method \'toString\' is not implemented in the TzDate mock'); + toThrowError('Method \'toString\' is not implemented in the TzDate mock'); }); }); @@ -170,7 +174,7 @@ describe('ngMock', function() { $log.reset(); })); - it("should skip debugging output if disabled (" + debugEnabled + ")", inject(function($log) { + it('should skip debugging output if disabled (' + debugEnabled + ')', inject(function($log) { $log.log('fake log'); $log.info('fake log'); $log.warn('fake log'); @@ -294,13 +298,15 @@ describe('ngMock', function() { expect(counter).toBe(1); $interval.flush(1000); - expect(counter).toBe(2); + + $interval.flush(2000); + expect(counter).toBe(4); })); it('should call $apply after each task is executed', inject(function($interval, $rootScope) { - var applySpy = spyOn($rootScope, '$apply').andCallThrough(); + var applySpy = spyOn($rootScope, '$apply').and.callThrough(); $interval(noop, 1000); expect(applySpy).not.toHaveBeenCalled(); @@ -308,27 +314,27 @@ describe('ngMock', function() { $interval.flush(1000); expect(applySpy).toHaveBeenCalledOnce(); - applySpy.reset(); + applySpy.calls.reset(); $interval(noop, 1000); $interval(noop, 1000); $interval.flush(1000); - expect(applySpy.callCount).toBe(3); + expect(applySpy).toHaveBeenCalledTimes(3); })); it('should NOT call $apply if invokeApply is set to false', inject(function($interval, $rootScope) { - var applySpy = spyOn($rootScope, '$apply').andCallThrough(); + var digestSpy = spyOn($rootScope, '$digest').and.callThrough(); var counter = 0; $interval(function increment() { counter++; }, 1000, 0, false); - expect(applySpy).not.toHaveBeenCalled(); + expect(digestSpy).not.toHaveBeenCalled(); expect(counter).toBe(0); $interval.flush(2000); - expect(applySpy).not.toHaveBeenCalled(); + expect(digestSpy).not.toHaveBeenCalled(); expect(counter).toBe(2); })); @@ -347,6 +353,75 @@ describe('ngMock', function() { })); + it('should allow you to NOT specify the delay time', inject(function($interval) { + var counterA = 0; + var counterB = 0; + + $interval(function() { counterA++; }); + $interval(function() { counterB++; }, 0); + + $interval.flush(100); + expect(counterA).toBe(100); + expect(counterB).toBe(100); + $interval.flush(100); + expect(counterA).toBe(200); + expect(counterB).toBe(200); + })); + + + it('should run tasks in correct relative order', inject(function($interval) { + var counterA = 0; + var counterB = 0; + $interval(function() { counterA++; }, 0); + $interval(function() { counterB++; }, 1000); + + $interval.flush(1000); + expect(counterA).toBe(1000); + expect(counterB).toBe(1); + $interval.flush(999); + expect(counterA).toBe(1999); + expect(counterB).toBe(1); + $interval.flush(1); + expect(counterA).toBe(2000); + expect(counterB).toBe(2); + })); + + + it('should NOT trigger zero-delay interval when flush has ran before', inject(function($interval) { + var counterA = 0; + var counterB = 0; + + $interval.flush(100); + + $interval(function() { counterA++; }); + $interval(function() { counterB++; }, 0); + + expect(counterA).toBe(0); + expect(counterB).toBe(0); + + $interval.flush(100); + + expect(counterA).toBe(100); + expect(counterB).toBe(100); + })); + + + it('should trigger zero-delay interval only once on flush zero', inject(function($interval) { + var counterA = 0; + var counterB = 0; + + $interval(function() { counterA++; }); + $interval(function() { counterB++; }, 0); + + $interval.flush(0); + expect(counterA).toBe(1); + expect(counterB).toBe(1); + $interval.flush(0); + expect(counterA).toBe(1); + expect(counterB).toBe(1); + })); + + it('should allow you to specify a number of iterations', inject(function($interval) { var counter = 0; $interval(function() {counter++;}, 1000, 2); @@ -424,22 +499,22 @@ describe('ngMock', function() { it('should delegate exception to the $exceptionHandler service', inject( function($interval, $exceptionHandler) { - $interval(function() { throw "Test Error"; }, 1000); + $interval(function() { throw 'Test Error'; }, 1000); expect($exceptionHandler.errors).toEqual([]); $interval.flush(1000); - expect($exceptionHandler.errors).toEqual(["Test Error"]); + expect($exceptionHandler.errors).toEqual(['Test Error']); $interval.flush(1000); - expect($exceptionHandler.errors).toEqual(["Test Error", "Test Error"]); + expect($exceptionHandler.errors).toEqual(['Test Error', 'Test Error']); })); it('should call $apply even if an exception is thrown in callback', inject( function($interval, $rootScope) { - var applySpy = spyOn($rootScope, '$apply').andCallThrough(); + var applySpy = spyOn($rootScope, '$apply').and.callThrough(); - $interval(function() { throw "Test Error"; }, 1000); + $interval(function() { throw new Error('Test Error'); }, 1000); expect(applySpy).not.toHaveBeenCalled(); $interval.flush(1000); @@ -450,7 +525,7 @@ describe('ngMock', function() { it('should still update the interval promise when an exception is thrown', inject(function($interval) { var log = [], - promise = $interval(function() { throw "Some Error"; }, 1000); + promise = $interval(function() { throw new Error('Some Error'); }, 1000); promise.then(function(value) { log.push('promise success: ' + value); }, function(err) { log.push('promise error: ' + err); }, @@ -528,7 +603,7 @@ describe('ngMock', function() { }); - describe('defer', function() { + describe('$browser', function() { var browser, log; beforeEach(inject(function($browser) { browser = $browser; @@ -541,47 +616,292 @@ describe('ngMock', function() { }; } - it('should flush', function() { - browser.defer(logFn('A')); - expect(log).toEqual(''); - browser.defer.flush(); - expect(log).toEqual('A;'); + describe('defer.flush', function() { + it('should flush', function() { + browser.defer(logFn('A')); + browser.defer(logFn('B'), null, 'taskType'); + expect(log).toEqual(''); + + browser.defer.flush(); + expect(log).toEqual('A;B;'); + }); + + it('should flush delayed', function() { + browser.defer(logFn('A')); + browser.defer(logFn('B'), 0, 'taskTypeB'); + browser.defer(logFn('C'), 10, 'taskTypeC'); + browser.defer(logFn('D'), 20); + expect(log).toEqual(''); + expect(browser.defer.now).toEqual(0); + + browser.defer.flush(0); + expect(log).toEqual('A;B;'); + + browser.defer.flush(); + expect(log).toEqual('A;B;C;D;'); + }); + + it('should defer and flush over time', function() { + browser.defer(logFn('A'), 1); + browser.defer(logFn('B'), 2, 'taskType'); + browser.defer(logFn('C'), 3); + + browser.defer.flush(0); + expect(browser.defer.now).toEqual(0); + expect(log).toEqual(''); + + browser.defer.flush(1); + expect(browser.defer.now).toEqual(1); + expect(log).toEqual('A;'); + + browser.defer.flush(2); + expect(browser.defer.now).toEqual(3); + expect(log).toEqual('A;B;C;'); + }); + + it('should throw an exception if there is nothing to be flushed', function() { + expect(function() {browser.defer.flush();}).toThrowError('No deferred tasks to be flushed'); + }); + + it('should not throw an exception when passing a specific delay', function() { + expect(function() {browser.defer.flush(100);}).not.toThrow(); + }); + + describe('tasks scheduled during flushing', function() { + it('should be flushed if they do not exceed the target delay (when no delay specified)', + function() { + browser.defer(function() { + logFn('1')(); + browser.defer(function() { + logFn('3')(); + browser.defer(logFn('4'), 1); + }, 2); + }, 1); + browser.defer(function() { + logFn('2')(); + browser.defer(logFn('6'), 4); + }, 2); + browser.defer(logFn('5'), 5); + + browser.defer.flush(0); + expect(browser.defer.now).toEqual(0); + expect(log).toEqual(''); + + browser.defer.flush(); + expect(browser.defer.now).toEqual(5); + expect(log).toEqual('1;2;3;4;5;'); + } + ); + + it('should be flushed if they do not exceed the specified delay', + function() { + browser.defer(function() { + logFn('1')(); + browser.defer(function() { + logFn('3')(); + browser.defer(logFn('4'), 1); + }, 2); + }, 1); + browser.defer(function() { + logFn('2')(); + browser.defer(logFn('6'), 4); + }, 2); + browser.defer(logFn('5'), 5); + + browser.defer.flush(0); + expect(browser.defer.now).toEqual(0); + expect(log).toEqual(''); + + browser.defer.flush(4); + expect(browser.defer.now).toEqual(4); + expect(log).toEqual('1;2;3;4;'); + + browser.defer.flush(6); + expect(browser.defer.now).toEqual(10); + expect(log).toEqual('1;2;3;4;5;6;'); + } + ); + }); + }); + + describe('defer.cancel', function() { + it('should cancel a pending task', function() { + var taskId1 = browser.defer(logFn('A'), 100, 'fooType'); + var taskId2 = browser.defer(logFn('B'), 200); + + expect(log).toBe(''); + expect(function() {browser.defer.verifyNoPendingTasks('fooType');}).toThrow(); + expect(function() {browser.defer.verifyNoPendingTasks();}).toThrow(); + + browser.defer.cancel(taskId1); + expect(function() {browser.defer.verifyNoPendingTasks('fooType');}).not.toThrow(); + expect(function() {browser.defer.verifyNoPendingTasks();}).toThrow(); + + browser.defer.cancel(taskId2); + expect(function() {browser.defer.verifyNoPendingTasks('fooType');}).not.toThrow(); + expect(function() {browser.defer.verifyNoPendingTasks();}).not.toThrow(); + + browser.defer.flush(1000); + expect(log).toBe(''); + }); + }); + + describe('defer.verifyNoPendingTasks', function() { + it('should throw if there are pending tasks', function() { + expect(browser.defer.verifyNoPendingTasks).not.toThrow(); + + browser.defer(noop); + expect(browser.defer.verifyNoPendingTasks).toThrow(); + }); + + it('should list the pending tasks (in order) in the error message', function() { + browser.defer(noop, 100); + browser.defer(noop, 300, 'fooType'); + browser.defer(noop, 200, 'barType'); + + var expectedError = + 'Deferred tasks to flush (3):\n' + + ' {id: 0, type: $$default$$, time: 100}\n' + + ' {id: 2, type: barType, time: 200}\n' + + ' {id: 1, type: fooType, time: 300}'; + expect(browser.defer.verifyNoPendingTasks).toThrowError(expectedError); + }); + + describe('with specific task type', function() { + it('should throw if there are pending tasks', function() { + browser.defer(noop, 0, 'fooType'); + + expect(function() {browser.defer.verifyNoPendingTasks('barType');}).not.toThrow(); + expect(function() {browser.defer.verifyNoPendingTasks('fooType');}).toThrow(); + expect(function() {browser.defer.verifyNoPendingTasks();}).toThrow(); + }); + + it('should list the pending tasks (in order) in the error message', function() { + browser.defer(noop, 100); + browser.defer(noop, 300, 'fooType'); + browser.defer(noop, 200, 'barType'); + browser.defer(noop, 400, 'fooType'); + + var expectedError = + 'Deferred tasks to flush (2):\n' + + ' {id: 1, type: fooType, time: 300}\n' + + ' {id: 3, type: fooType, time: 400}'; + expect(function() {browser.defer.verifyNoPendingTasks('fooType');}). + toThrowError(expectedError); + }); + }); }); - it('should flush delayed', function() { - browser.defer(logFn('A')); - browser.defer(logFn('B'), 10); - browser.defer(logFn('C'), 20); - expect(log).toEqual(''); + describe('notifyWhenNoOutstandingRequests', function() { + var callback; + beforeEach(function() { + callback = jasmine.createSpy('callback'); + }); + + it('should immediately run the callback if no pending tasks', function() { + browser.notifyWhenNoOutstandingRequests(callback); + expect(callback).toHaveBeenCalled(); + }); + + it('should run the callback as soon as there are no pending tasks', function() { + browser.defer(noop, 100); + browser.defer(noop, 200); + + browser.notifyWhenNoOutstandingRequests(callback); + expect(callback).not.toHaveBeenCalled(); + + browser.defer.flush(100); + expect(callback).not.toHaveBeenCalled(); + + browser.defer.flush(100); + expect(callback).toHaveBeenCalled(); + }); + + it('should not run the callback more than once', function() { + browser.defer(noop, 100); + browser.notifyWhenNoOutstandingRequests(callback); + expect(callback).not.toHaveBeenCalled(); + + browser.defer.flush(100); + expect(callback).toHaveBeenCalledOnce(); + + browser.defer(noop, 200); + browser.defer.flush(100); + expect(callback).toHaveBeenCalledOnce(); + }); + + describe('with specific task type', function() { + it('should immediately run the callback if no pending tasks', function() { + browser.notifyWhenNoOutstandingRequests(callback, 'fooType'); + expect(callback).toHaveBeenCalled(); + }); + + it('should run the callback as soon as there are no pending tasks', function() { + browser.defer(noop, 100, 'fooType'); + browser.defer(noop, 200, 'barType'); + + browser.notifyWhenNoOutstandingRequests(callback, 'fooType'); + expect(callback).not.toHaveBeenCalled(); + + browser.defer.flush(100); + expect(callback).toHaveBeenCalled(); + }); - expect(browser.defer.now).toEqual(0); - browser.defer.flush(0); - expect(log).toEqual('A;'); + it('should not run the callback more than once', function() { + browser.defer(noop, 100, 'fooType'); + browser.defer(noop, 200); - browser.defer.flush(); - expect(log).toEqual('A;B;C;'); + browser.notifyWhenNoOutstandingRequests(callback, 'fooType'); + expect(callback).not.toHaveBeenCalled(); + + browser.defer.flush(100); + expect(callback).toHaveBeenCalledOnce(); + + browser.defer.flush(100); + expect(callback).toHaveBeenCalledOnce(); + + browser.defer(noop, 100, 'fooType'); + browser.defer(noop, 200); + browser.defer.flush(); + expect(callback).toHaveBeenCalledOnce(); + }); + }); }); + }); - it('should defer and flush over time', function() { - browser.defer(logFn('A'), 1); - browser.defer(logFn('B'), 2); - browser.defer(logFn('C'), 3); - browser.defer.flush(0); - expect(browser.defer.now).toEqual(0); - expect(log).toEqual(''); + describe('$flushPendingTasks', function() { + var $flushPendingTasks; + var browserDeferFlushSpy; + + beforeEach(inject(function($browser, _$flushPendingTasks_) { + $flushPendingTasks = _$flushPendingTasks_; + browserDeferFlushSpy = spyOn($browser.defer, 'flush').and.returnValue('flushed'); + })); - browser.defer.flush(1); - expect(browser.defer.now).toEqual(1); - expect(log).toEqual('A;'); + it('should delegate to `$browser.defer.flush()`', function() { + var result = $flushPendingTasks(42); - browser.defer.flush(2); - expect(browser.defer.now).toEqual(3); - expect(log).toEqual('A;B;C;'); + expect(browserDeferFlushSpy).toHaveBeenCalledOnceWith(42); + expect(result).toBe('flushed'); }); + }); + + + describe('$verifyNoPendingTasks', function() { + var $verifyNoPendingTasks; + var browserDeferVerifySpy; + + beforeEach(inject(function($browser, _$verifyNoPendingTasks_) { + $verifyNoPendingTasks = _$verifyNoPendingTasks_; + browserDeferVerifySpy = spyOn($browser.defer, 'verifyNoPendingTasks').and.returnValue('verified'); + })); + + it('should delegate to `$browser.defer.verifyNoPendingTasks()`', function() { + var result = $verifyNoPendingTasks('fortyTwo'); - it('should throw an exception if there is nothing to be flushed', function() { - expect(function() {browser.defer.flush();}).toThrow('No deferred tasks to be flushed'); + expect(browserDeferVerifySpy).toHaveBeenCalledOnceWith('fortyTwo'); + expect(result).toBe('verified'); }); }); @@ -622,58 +942,84 @@ describe('ngMock', function() { module(function($exceptionHandlerProvider) { expect(function() { $exceptionHandlerProvider.mode('XXX'); - }).toThrow("Unknown mode 'XXX', only 'log'/'rethrow' modes are allowed!"); + }).toThrowError('Unknown mode \'XXX\', only \'log\'/\'rethrow\' modes are allowed!'); }); inject(); // Trigger the tests in `module` }); - }); describe('$timeout', function() { it('should expose flush method that will flush the pending queue of tasks', inject( - function($timeout) { + function($rootScope, $timeout) { var logger = [], logFn = function(msg) { return function() { logger.push(msg); }; }; $timeout(logFn('t1')); $timeout(logFn('t2'), 200); + $rootScope.$evalAsync(logFn('rs')); // Non-timeout tasks are flushed as well. $timeout(logFn('t3')); expect(logger).toEqual([]); $timeout.flush(); - expect(logger).toEqual(['t1', 't3', 't2']); + expect(logger).toEqual(['t1', 'rs', 't3', 't2']); })); - it('should throw an exception when not flushed', inject(function($timeout) { - $timeout(noop); + it('should throw an exception when not flushed', inject(function($rootScope, $timeout) { + $timeout(noop, 100); + $rootScope.$evalAsync(noop); - var expectedError = 'Deferred tasks to flush (1): {id: 0, time: 0}'; - expect(function() {$timeout.verifyNoPendingTasks();}).toThrow(expectedError); + var expectedError = + 'Deferred tasks to flush (2):\n' + + ' {id: 1, type: $evalAsync, time: 0}\n' + + ' {id: 0, type: $timeout, time: 100}'; + expect($timeout.verifyNoPendingTasks).toThrowError(expectedError); })); - it('should do nothing when all tasks have been flushed', inject(function($timeout) { - $timeout(noop); + it('should recommend `$verifyNoPendingTasks()` when all pending tasks are not timeouts', + inject(function($rootScope, $timeout) { + var extraMessage = 'None of the pending tasks are timeouts. If you only want to verify ' + + 'pending timeouts, use `$verifyNoPendingTasks(\'$timeout\')` instead.'; + var errorMessage; + + $timeout(noop, 100); + $rootScope.$evalAsync(noop); + try { $timeout.verifyNoPendingTasks(); } catch (err) { errorMessage = err.message; } + + expect(errorMessage).not.toContain(extraMessage); + + $timeout.flush(100); + $rootScope.$evalAsync(noop); + try { $timeout.verifyNoPendingTasks(); } catch (err) { errorMessage = err.message; } + + expect(errorMessage).toContain(extraMessage); + }) + ); + + + it('should do nothing when all tasks have been flushed', inject(function($rootScope, $timeout) { + $timeout(noop, 100); + $rootScope.$evalAsync(noop); $timeout.flush(); - expect(function() {$timeout.verifyNoPendingTasks();}).not.toThrow(); + expect($timeout.verifyNoPendingTasks).not.toThrow(); })); it('should check against the delay if provided within timeout', inject(function($timeout) { $timeout(noop, 100); $timeout.flush(100); - expect(function() {$timeout.verifyNoPendingTasks();}).not.toThrow(); + expect($timeout.verifyNoPendingTasks).not.toThrow(); $timeout(noop, 1000); $timeout.flush(100); - expect(function() {$timeout.verifyNoPendingTasks();}).toThrow(); + expect($timeout.verifyNoPendingTasks).toThrow(); $timeout.flush(900); - expect(function() {$timeout.verifyNoPendingTasks();}).not.toThrow(); + expect($timeout.verifyNoPendingTasks).not.toThrow(); })); @@ -690,6 +1036,31 @@ describe('ngMock', function() { $timeout.flush(123); expect(count).toBe(2); })); + + + it('should resolve timeout functions following the timeline', inject(function($timeout) { + var count1 = 0, count2 = 0; + var iterate1 = function() { + count1++; + $timeout(iterate1, 100); + }; + var iterate2 = function() { + count2++; + $timeout(iterate2, 150); + }; + + $timeout(iterate1, 100); + $timeout(iterate2, 150); + $timeout.flush(150); + expect(count1).toBe(1); + expect(count2).toBe(1); + $timeout.flush(50); + expect(count1).toBe(2); + expect(count2).toBe(1); + $timeout.flush(400); + expect(count1).toBe(6); + expect(count2).toBe(4); + })); }); @@ -718,9 +1089,6 @@ describe('ngMock', function() { })); it('should serialize scope that has overridden "hasOwnProperty"', inject(function($rootScope, $sniffer) { - /* jshint -W001 */ - // MS IE8 just doesn't work for this kind of thing, since "for ... in" doesn't return - // things like hasOwnProperty even if it is explicitly defined on the actual object! $rootScope.hasOwnProperty = 'X'; expect(d($rootScope)).toMatch(/Scope\(.*\): \{/); expect(d($rootScope)).toMatch(/hasOwnProperty: "X"/); @@ -741,11 +1109,13 @@ describe('ngMock', function() { var mock = { log: 'module' }; beforeEach(function() { + angular.module('stringRefModule', []).service('stringRef', function() {}); + module({ 'service': mock, 'other': { some: 'replacement'} }, - 'ngResource', + 'stringRefModule', function($provide) { $provide.value('example', 'win'); } ); }); @@ -764,30 +1134,13 @@ describe('ngMock', function() { }); it('should integrate with string and function', function() { - inject(function(service, $resource, example) { + inject(function(service, stringRef, example) { expect(service).toEqual(mock); - expect($resource).toBeDefined(); + expect(stringRef).toBeDefined(); expect(example).toEqual('win'); }); }); - describe('module cleanup', function() { - function testFn() { - - } - - it('should add hashKey to module function', function() { - module(testFn); - inject(function() { - expect(testFn.$$hashKey).toBeDefined(); - }); - }); - - it('should cleanup hashKey after previous test', function() { - expect(testFn.$$hashKey).toBeUndefined(); - }); - }); - describe('$inject cleanup', function() { function testFn() { @@ -833,6 +1186,19 @@ describe('ngMock', function() { }); }); + describe('nested calls', function() { + it('should invoke nested module calls immediately', function() { + module(function($provide) { + $provide.constant('someConst', 'blah'); + module(function(someConst) { + log = someConst; + }); + }); + inject(function() { + expect(log).toBe('blah'); + }); + }); + }); describe('inline in test', function() { it('should load module', function() { @@ -886,36 +1252,12 @@ describe('ngMock', function() { }); }); - - describe('this', function() { - - it('should set `this` to be the jasmine context', inject(function() { - expect(this instanceof jasmine.Spec).toBe(true); - })); - - it('should set `this` to be the jasmine context when inlined in a test', function() { - var tested = false; - - inject(function() { - expect(this instanceof jasmine.Spec).toBe(true); - tested = true; - }); - - expect(tested).toBe(true); - }); - }); - - - // We don't run the following tests on IE8. - // IE8 throws "Object does not support this property or method." error, - // when thrown from a function defined on window (which `inject` is). - it('should not change thrown Errors', inject(function($sniffer) { expect(function() { inject(function() { throw new Error('test message'); }); - }).toThrow('test message'); + }).toThrow(jasmine.objectContaining({message: 'test message'})); })); it('should not change thrown strings', inject(function($sniffer) { @@ -925,43 +1267,111 @@ describe('ngMock', function() { }); }).toThrow('test message'); })); + + describe('error stack trace when called outside of spec context', function() { + // - Chrome, Firefox, Edge give us the stack trace as soon as an Error is created + // - IE10+, PhantomJS give us the stack trace only once the error is thrown + // - IE9 does not provide stack traces + var stackTraceSupported = (function() { + var error = new Error(); + if (!error.stack) { + try { + throw error; + } catch (e) { /* empty */} + } + + return !!error.stack; + })(); + + function testCaller() { + return inject(function injectableError() { + throw new Error(); + }); + } + var throwErrorFromInjectCallback = testCaller(); + + if (stackTraceSupported) { + describe('on browsers supporting stack traces', function() { + it('should update thrown Error stack trace with inject call location', function() { + try { + throwErrorFromInjectCallback(); + } catch (e) { + expect(e.stack).toMatch('injectableError'); + } + }); + }); + } else { + describe('on browsers not supporting stack traces', function() { + it('should not add stack trace information to thrown Error', function() { + try { + throwErrorFromInjectCallback(); + } catch (e) { + expect(e.stack).toBeUndefined(); + } + }); + }); + } + }); + + describe('ErrorAddingDeclarationLocationStack', function() { + it('should be caught by Jasmine\'s `toThrowError()`', function() { + function throwErrorAddingDeclarationStack() { + module(function($provide) { + $provide.factory('badFactory', function() { + throw new Error('BadFactoryError'); + }); + }); + + inject(function(badFactory) {}); + } + + expect(throwErrorAddingDeclarationStack).toThrowError(/BadFactoryError/); + }); + }); }); }); describe('$httpBackend', function() { - var hb, callback, realBackendSpy; + var hb, callback; beforeEach(inject(function($httpBackend) { callback = jasmine.createSpy('callback'); hb = $httpBackend; })); + it('should provide "expect" methods for each HTTP verb', function() { - expect(typeof hb.expectGET).toBe("function"); - expect(typeof hb.expectPOST).toBe("function"); - expect(typeof hb.expectPUT).toBe("function"); - expect(typeof hb.expectPATCH).toBe("function"); - expect(typeof hb.expectDELETE).toBe("function"); - expect(typeof hb.expectHEAD).toBe("function"); + expect(typeof hb.expectGET).toBe('function'); + expect(typeof hb.expectPOST).toBe('function'); + expect(typeof hb.expectPUT).toBe('function'); + expect(typeof hb.expectPATCH).toBe('function'); + expect(typeof hb.expectDELETE).toBe('function'); + expect(typeof hb.expectHEAD).toBe('function'); }); it('should provide "when" methods for each HTTP verb', function() { - expect(typeof hb.whenGET).toBe("function"); - expect(typeof hb.whenPOST).toBe("function"); - expect(typeof hb.whenPUT).toBe("function"); - expect(typeof hb.whenPATCH).toBe("function"); - expect(typeof hb.whenDELETE).toBe("function"); - expect(typeof hb.whenHEAD).toBe("function"); + expect(typeof hb.whenGET).toBe('function'); + expect(typeof hb.whenPOST).toBe('function'); + expect(typeof hb.whenPUT).toBe('function'); + expect(typeof hb.whenPATCH).toBe('function'); + expect(typeof hb.whenDELETE).toBe('function'); + expect(typeof hb.whenHEAD).toBe('function'); }); - it('should respond with first matched definition', function() { + it('should provide "route" shortcuts for expect and when', function() { + expect(typeof hb.whenRoute).toBe('function'); + expect(typeof hb.expectRoute).toBe('function'); + }); + + + it('should respond with first matched definition by default', function() { hb.when('GET', '/url1').respond(200, 'content', {}); hb.when('GET', '/url1').respond(201, 'another', {}); - callback.andCallFake(function(status, response) { + callback.and.callFake(function(status, response) { expect(status).toBe(200); expect(response).toBe('content'); }); @@ -973,12 +1383,84 @@ describe('ngMock', function() { }); + describe('matchLatestDefinitionEnabled()', function() { + + it('should be set to false by default', function() { + expect(hb.matchLatestDefinitionEnabled()).toBe(false); + }); + + + it('should allow to change the value', function() { + hb.matchLatestDefinitionEnabled(true); + expect(hb.matchLatestDefinitionEnabled()).toBe(true); + }); + + + it('should return the httpBackend when used as a setter', function() { + expect(hb.matchLatestDefinitionEnabled(true)).toBe(hb); + }); + + + it('should respond with the first matched definition when false', + function() { + hb.matchLatestDefinitionEnabled(false); + + hb.when('GET', '/url1').respond(200, 'content', {}); + hb.when('GET', '/url1').respond(201, 'another', {}); + + callback.and.callFake(function(status, response) { + expect(status).toBe(200); + expect(response).toBe('content'); + }); + + hb('GET', '/url1', null, callback); + expect(callback).not.toHaveBeenCalled(); + hb.flush(); + expect(callback).toHaveBeenCalledOnce(); + } + ); + + + it('should respond with latest matched definition when true', + function() { + hb.matchLatestDefinitionEnabled(true); + + hb.when('GET', '/url1').respond(200, 'match1', {}); + hb.when('GET', '/url1').respond(200, 'match2', {}); + hb.when('GET', '/url2').respond(204, 'nomatch', {}); + + callback.and.callFake(function(status, response) { + expect(status).toBe(200); + expect(response).toBe('match2'); + }); + + hb('GET', '/url1', null, callback); + + // Check if a newly added match is used + hb.when('GET', '/url1').respond(201, 'match3', {}); + + var callback2 = jasmine.createSpy(); + + callback2.and.callFake(function(status, response) { + expect(status).toBe(201); + expect(response).toBe('match3'); + }); + + hb('GET', '/url1', null, callback2); + expect(callback).not.toHaveBeenCalled(); + hb.flush(); + expect(callback).toHaveBeenCalledOnce(); + } + ); + }); + + it('should respond with a copy of the mock data', function() { var mockObject = {a: 'b'}; hb.when('GET', '/url1').respond(200, mockObject, {}); - callback.andCallFake(function(status, response) { + callback.and.callFake(function(status, response) { expect(status).toBe(200); expect(response).toEqual({a: 'b'}); expect(response).not.toBe(mockObject); @@ -991,7 +1473,7 @@ describe('ngMock', function() { // Fire it again and verify that the returned mock data has not been // modified. - callback.reset(); + callback.calls.reset(); hb('GET', '/url1', null, callback); hb.flush(); expect(callback).toHaveBeenCalledOnce(); @@ -999,11 +1481,68 @@ describe('ngMock', function() { }); + it('should be able to handle Blobs as mock data', function() { + if (typeof Blob !== 'undefined') { + // eslint-disable-next-line no-undef + var mockBlob = new Blob(['{"foo":"bar"}'], {type: 'application/json'}); + + hb.when('GET', '/url1').respond(200, mockBlob, {}); + + callback.and.callFake(function(status, response) { + expect(response).not.toBe(mockBlob); + expect(response.size).toBe(13); + expect(response.type).toBe('application/json'); + expect(response.toString()).toBe('[object Blob]'); + }); + + hb('GET', '/url1', null, callback); + hb.flush(); + expect(callback).toHaveBeenCalledOnce(); + } + }); + + it('should throw error when unexpected request', function() { hb.when('GET', '/url1').respond(200, 'content'); expect(function() { hb('GET', '/xxx'); - }).toThrow('Unexpected request: GET /xxx\nNo more request expected'); + }).toThrowError('Unexpected request: GET /xxx\nNo more request expected'); + }); + + + it('should throw error when expectation fails', function() { + expect(function() { + hb.expectPOST('/some', {foo: 1}).respond({}); + hb('POST', '/some', {foo: 2}, callback); + hb.flush(); + }).toThrowError(/^Expected POST \/some with different data/); + }); + + + it('should throw error when expectation about headers fails', function() { + expect(function() { + hb.expectPOST('/some', {foo: 1}, {X: 'val1'}).respond({}); + hb('POST', '/some', {foo: 1}, callback, {X: 'val2'}); + hb.flush(); + }).toThrowError(/^Expected POST \/some with different headers/); + }); + + + it('should throw error about data when expectations about both data and headers fail', function() { + expect(function() { + hb.expectPOST('/some', {foo: 1}, {X: 'val1'}).respond({}); + hb('POST', '/some', {foo: 2}, callback, {X: 'val2'}); + hb.flush(); + }).toThrowError(/^Expected POST \/some with different data/); + }); + + + it('should throw error when response is not defined for a backend definition', function() { + expect(function() { + hb.whenGET('/some'); // no .respond(...) ! + hb('GET', '/some', null, callback); + hb.flush(); + }).toThrowError('No response defined !'); }); @@ -1074,7 +1613,7 @@ describe('ngMock', function() { it('should match only method', function() { hb.when('GET').respond(202, 'c'); - callback.andCallFake(function(status, response) { + callback.and.callFake(function(status, response) { expect(status).toBe(202); expect(response).toBe('c'); }); @@ -1088,18 +1627,109 @@ describe('ngMock', function() { }); - it('should preserve the order of requests', function() { - hb.when('GET', '/url1').respond(200, 'first'); - hb.when('GET', '/url2').respond(201, 'second'); + it('should not error if the url is not provided', function() { + expect(function() { + hb.when('GET'); + + hb.whenGET(); + hb.whenPOST(); + hb.whenPUT(); + hb.whenPATCH(); + hb.whenDELETE(); + hb.whenHEAD(); + + hb.expect('GET'); + + hb.expectGET(); + hb.expectPOST(); + hb.expectPUT(); + hb.expectPATCH(); + hb.expectDELETE(); + hb.expectHEAD(); + }).not.toThrow(); + }); - hb('GET', '/url2', null, callback); - hb('GET', '/url1', null, callback); + + it('should error if the url is undefined', function() { + expect(function() { + hb.when('GET', undefined); + }).toThrowError('Undefined argument `url`; the argument is provided but not defined'); + + expect(function() { + hb.whenGET(undefined); + }).toThrowError('Undefined argument `url`; the argument is provided but not defined'); + + expect(function() { + hb.whenDELETE(undefined); + }).toThrowError('Undefined argument `url`; the argument is provided but not defined'); + + expect(function() { + hb.whenJSONP(undefined); + }).toThrowError('Undefined argument `url`; the argument is provided but not defined'); + + expect(function() { + hb.whenHEAD(undefined); + }).toThrowError('Undefined argument `url`; the argument is provided but not defined'); + + expect(function() { + hb.whenPATCH(undefined); + }).toThrowError('Undefined argument `url`; the argument is provided but not defined'); + + expect(function() { + hb.whenPOST(undefined); + }).toThrowError('Undefined argument `url`; the argument is provided but not defined'); + + expect(function() { + hb.whenPUT(undefined); + }).toThrowError('Undefined argument `url`; the argument is provided but not defined'); + + + expect(function() { + hb.expect('GET', undefined); + }).toThrowError('Undefined argument `url`; the argument is provided but not defined'); + + expect(function() { + hb.expectGET(undefined); + }).toThrowError('Undefined argument `url`; the argument is provided but not defined'); + + expect(function() { + hb.expectDELETE(undefined); + }).toThrowError('Undefined argument `url`; the argument is provided but not defined'); + + expect(function() { + hb.expectJSONP(undefined); + }).toThrowError('Undefined argument `url`; the argument is provided but not defined'); + + expect(function() { + hb.expectHEAD(undefined); + }).toThrowError('Undefined argument `url`; the argument is provided but not defined'); + + expect(function() { + hb.expectPATCH(undefined); + }).toThrowError('Undefined argument `url`; the argument is provided but not defined'); + + expect(function() { + hb.expectPOST(undefined); + }).toThrowError('Undefined argument `url`; the argument is provided but not defined'); + + expect(function() { + hb.expectPUT(undefined); + }).toThrowError('Undefined argument `url`; the argument is provided but not defined'); + }); + + + it('should preserve the order of requests', function() { + hb.when('GET', '/url1').respond(200, 'first'); + hb.when('GET', '/url2').respond(201, 'second'); + + hb('GET', '/url2', null, callback); + hb('GET', '/url1', null, callback); hb.flush(); - expect(callback.callCount).toBe(2); - expect(callback.argsForCall[0]).toEqual([201, 'second', '', '']); - expect(callback.argsForCall[1]).toEqual([200, 'first', '', '']); + expect(callback).toHaveBeenCalledTimes(2); + expect(callback.calls.argsFor(0)).toEqual([201, 'second', '', '', 'complete']); + expect(callback.calls.argsFor(1)).toEqual([200, 'first', '', '', 'complete']); }); @@ -1109,11 +1739,11 @@ describe('ngMock', function() { hb('GET', '/url1', undefined, callback); hb.flush(); - expect(callback).toHaveBeenCalledOnceWith(200, 'first', 'header: val', 'OK'); + expect(callback).toHaveBeenCalledOnceWith(200, 'first', 'header: val', 'OK', 'complete'); }); it('should default status code to 200', function() { - callback.andCallFake(function(status, response) { + callback.and.callFake(function(status, response) { expect(status).toBe(200); expect(response).toBe('some-data'); }); @@ -1124,7 +1754,7 @@ describe('ngMock', function() { hb('GET', '/url2', null, callback); hb.flush(); expect(callback).toHaveBeenCalled(); - expect(callback.callCount).toBe(2); + expect(callback).toHaveBeenCalledTimes(2); }); it('should default status code to 200 and provide status text', function() { @@ -1132,18 +1762,54 @@ describe('ngMock', function() { hb('GET', '/url1', null, callback); hb.flush(); - expect(callback).toHaveBeenCalledOnceWith(200, 'first', 'header: val', 'OK'); + expect(callback).toHaveBeenCalledOnceWith(200, 'first', 'header: val', 'OK', 'complete'); + }); + + it('should default xhrStatus to complete', function() { + callback.and.callFake(function(status, response, headers, x, xhrStatus) { + expect(xhrStatus).toBe('complete'); + }); + + hb.expect('GET', '/url1').respond('some-data'); + hb('GET', '/url1', null, callback); + + hb.flush(); + expect(callback).toHaveBeenCalled(); }); it('should take function', function() { - hb.expect('GET', '/some').respond(function(m, u, d, h) { - return [301, m + u + ';' + d + ';a=' + h.a, {'Connection': 'keep-alive'}, 'Moved Permanently']; + hb.expect('GET', '/some?q=s').respond(function(m, u, d, h, p) { + return [301, m + u + ';' + d + ';a=' + h.a + ';q=' + p.q, {'Connection': 'keep-alive'}, 'Moved Permanently']; }); - hb('GET', '/some', 'data', callback, {a: 'b'}); + hb('GET', '/some?q=s', 'data', callback, {a: 'b'}); hb.flush(); - expect(callback).toHaveBeenCalledOnceWith(301, 'GET/some;data;a=b', 'Connection: keep-alive', 'Moved Permanently'); + expect(callback).toHaveBeenCalledOnceWith(301, 'GET/some?q=s;data;a=b;q=s', 'Connection: keep-alive', 'Moved Permanently', undefined); + }); + + it('should decode query parameters in respond() function', function() { + hb.expect('GET', '/url?query=l%E2%80%A2ng%20string%20w%2F%20spec%5Eal%20char%24&id=1234&orderBy=-name') + .respond(function(m, u, d, h, p) { + return [200, 'id=' + p.id + ';orderBy=' + p.orderBy + ';query=' + p.query]; + }); + + hb('GET', '/url?query=l%E2%80%A2ng%20string%20w%2F%20spec%5Eal%20char%24&id=1234&orderBy=-name', null, callback); + hb.flush(); + + expect(callback).toHaveBeenCalledOnceWith(200, 'id=1234;orderBy=-name;query=l•ng string w/ spec^al char$', '', '', undefined); + }); + + it('should include regex captures in respond() params when keys provided', function() { + hb.expect('GET', /\/(.+)\/article\/(.+)/, undefined, undefined, ['id', 'name']) + .respond(function(m, u, d, h, p) { + return [200, 'id=' + p.id + ';name=' + p.name]; + }); + + hb('GET', '/1234/article/cool-angular-article', null, callback); + hb.flush(); + + expect(callback).toHaveBeenCalledOnceWith(200, 'id=1234;name=cool-angular-article', '', '', undefined); }); it('should default response headers to ""', function() { @@ -1155,9 +1821,9 @@ describe('ngMock', function() { hb.flush(); - expect(callback.callCount).toBe(2); - expect(callback.argsForCall[0]).toEqual([200, 'first', '', '']); - expect(callback.argsForCall[1]).toEqual([200, 'second', '', '']); + expect(callback).toHaveBeenCalledTimes(2); + expect(callback.calls.argsFor(0)).toEqual([200, 'first', '', '', 'complete']); + expect(callback.calls.argsFor(1)).toEqual([200, 'second', '', '', 'complete']); }); it('should be able to override response of expect definition', function() { @@ -1167,7 +1833,7 @@ describe('ngMock', function() { hb('GET', '/url1', null, callback); hb.flush(); - expect(callback).toHaveBeenCalledOnceWith(200, 'second', '', ''); + expect(callback).toHaveBeenCalledOnceWith(200, 'second', '', '', 'complete'); }); it('should be able to override response of when definition', function() { @@ -1177,7 +1843,7 @@ describe('ngMock', function() { hb('GET', '/url1', null, callback); hb.flush(); - expect(callback).toHaveBeenCalledOnceWith(200, 'second', '', ''); + expect(callback).toHaveBeenCalledOnceWith(200, 'second', '', '', 'complete'); }); it('should be able to override response of expect definition with chaining', function() { @@ -1186,7 +1852,7 @@ describe('ngMock', function() { hb('GET', '/url1', null, callback); hb.flush(); - expect(callback).toHaveBeenCalledOnceWith(200, 'second', '', ''); + expect(callback).toHaveBeenCalledOnceWith(200, 'second', '', '', 'complete'); }); it('should be able to override response of when definition with chaining', function() { @@ -1195,7 +1861,7 @@ describe('ngMock', function() { hb('GET', '/url1', null, callback); hb.flush(); - expect(callback).toHaveBeenCalledOnceWith(200, 'second', '', ''); + expect(callback).toHaveBeenCalledOnceWith(200, 'second', '', '', 'complete'); }); }); @@ -1207,12 +1873,12 @@ describe('ngMock', function() { expect(function() { hb('GET', '/url2', null, noop, {}); - }).toThrow('Unexpected request: GET /url2\nExpected GET /url1'); + }).toThrowError('Unexpected request: GET /url2\nExpected GET /url1'); }); it('should have precedence over when()', function() { - callback.andCallFake(function(status, response) { + callback.and.callFake(function(status, response) { expect(status).toBe(300); expect(response).toBe('expect'); }); @@ -1232,8 +1898,8 @@ describe('ngMock', function() { expect(function() { hb('GET', '/match', null, noop, {}); - }).toThrow('Expected GET /match with different headers\n' + - 'EXPECTED: {"Content-Type":"application/json"}\nGOT: {}'); + }).toThrowError('Expected GET /match with different headers\n' + + 'EXPECTED: {"Content-Type":"application/json"}\nGOT: {}'); }); @@ -1243,8 +1909,8 @@ describe('ngMock', function() { expect(function() { hb('GET', '/match', 'different', noop, {}); - }).toThrow('Expected GET /match with different data\n' + - 'EXPECTED: some-data\nGOT: different'); + }).toThrowError('Expected GET /match with different data\n' + + 'EXPECTED: some-data\nGOT: different'); }); @@ -1269,13 +1935,13 @@ describe('ngMock', function() { expect(function() { hb('GET', '/match', '{"a":1,"b":3}', noop, {}); - }).toThrow('Expected GET /match with different data\n' + - 'EXPECTED: {"a":1,"b":2}\nGOT: {"a":1,"b":3}'); + }).toThrowError('Expected GET /match with different data\n' + + 'EXPECTED: {"a":1,"b":2}\nGOT: {"a":1,"b":3}'); }); - it("should use when's respond() when no expect() respond is defined", function() { - callback.andCallFake(function(status, response) { + it('should use when\'s respond() when no expect() respond is defined', function() { + callback.and.callFake(function(status, response) { expect(status).toBe(201); expect(response).toBe('data'); }); @@ -1311,7 +1977,37 @@ describe('ngMock', function() { hb.flush(2); expect(callback).toHaveBeenCalled(); - expect(callback.callCount).toBe(2); + expect(callback).toHaveBeenCalledTimes(2); + }); + + + it('should flush given number of pending requests beginning at specified request', function() { + var dontCallMe = jasmine.createSpy('dontCallMe'); + + hb.when('GET').respond(200, ''); + hb('GET', '/some', null, dontCallMe); + hb('GET', '/some', null, callback); + hb('GET', '/some', null, callback); + hb('GET', '/some', null, dontCallMe); + + hb.flush(2, 1); + expect(dontCallMe).not.toHaveBeenCalled(); + expect(callback).toHaveBeenCalledTimes(2); + }); + + + it('should flush all pending requests beginning at specified request', function() { + var dontCallMe = jasmine.createSpy('dontCallMe'); + + hb.when('GET').respond(200, ''); + hb('GET', '/some', null, dontCallMe); + hb('GET', '/some', null, dontCallMe); + hb('GET', '/some', null, callback); + hb('GET', '/some', null, callback); + + hb.flush(null, 2); + expect(dontCallMe).not.toHaveBeenCalled(); + expect(callback).toHaveBeenCalledTimes(2); }); @@ -1319,19 +2015,20 @@ describe('ngMock', function() { hb.when('GET').respond(200, ''); hb('GET', '/url', null, callback); - expect(function() {hb.flush(2);}).toThrow('No more pending request to flush !'); + expect(function() {hb.flush(2);}).toThrowError('No more pending request to flush !'); expect(callback).toHaveBeenCalledOnce(); }); it('should throw exception when no request to flush', function() { - expect(function() {hb.flush();}).toThrow('No pending request to flush !'); + expect(function() {hb.flush();}).toThrowError('No pending request to flush !'); hb.when('GET').respond(200, ''); hb('GET', '/some', null, callback); - hb.flush(); + expect(function() {hb.flush(null, 1);}).toThrowError('No pending request to flush !'); - expect(function() {hb.flush();}).toThrow('No pending request to flush !'); + hb.flush(); + expect(function() {hb.flush();}).toThrowError('No pending request to flush !'); }); @@ -1340,7 +2037,7 @@ describe('ngMock', function() { hb.expect('GET', '/url2').respond(); hb('GET', '/url1', null, angular.noop); - expect(function() {hb.flush();}).toThrow('Unsatisfied requests: GET /url2'); + expect(function() {hb.flush();}).toThrowError('Unsatisfied requests: GET /url2'); }); }); @@ -1348,7 +2045,7 @@ describe('ngMock', function() { it('should abort requests when timeout promise resolves', function() { hb.expect('GET', '/url1').respond(200); - var canceler, then = jasmine.createSpy('then').andCallFake(function(fn) { + var canceler, then = jasmine.createSpy('then').and.callFake(function(fn) { canceler = fn; }); @@ -1357,7 +2054,7 @@ describe('ngMock', function() { canceler(); // simulate promise resolution - expect(callback).toHaveBeenCalledWith(-1, undefined, ''); + expect(callback).toHaveBeenCalledWith(-1, undefined, '', undefined, 'abort'); hb.verifyNoOutstandingExpectation(); hb.verifyNoOutstandingRequest(); }); @@ -1369,7 +2066,7 @@ describe('ngMock', function() { hb('GET', '/url1', null, callback, null, 200); $timeout.flush(300); - expect(callback).toHaveBeenCalledWith(-1, undefined, ''); + expect(callback).toHaveBeenCalledWith(-1, undefined, '', undefined, 'timeout'); hb.verifyNoOutstandingExpectation(); hb.verifyNoOutstandingRequest(); })); @@ -1379,7 +2076,7 @@ describe('ngMock', function() { hb.when('GET', '/test'); expect(function() { hb('GET', '/test', null, callback); - }).toThrow('No response defined !'); + }).toThrowError('No response defined !'); }); @@ -1387,7 +2084,7 @@ describe('ngMock', function() { hb.expect('GET', '/url'); expect(function() { hb('GET', '/url', null, callback); - }).toThrow('No response defined !'); + }).toThrowError('No response defined !'); }); @@ -1415,7 +2112,7 @@ describe('ngMock', function() { hb('POST', '/u1', 'ddd', noop, {}); expect(function() {hb.verifyNoOutstandingExpectation();}). - toThrow('Unsatisfied requests: GET /u2, POST /u3'); + toThrowError('Unsatisfied requests: GET /u2, POST /u3'); }); @@ -1438,6 +2135,7 @@ describe('ngMock', function() { }); }); + describe('verifyRequests', function() { it('should throw exception if not all requests were flushed', function() { @@ -1446,7 +2144,35 @@ describe('ngMock', function() { expect(function() { hb.verifyNoOutstandingRequest(); - }).toThrow('Unflushed requests: 1'); + }).toThrowError('Unflushed requests: 1\n' + + ' GET /some'); + }); + + + it('should verify requests fired asynchronously', inject(function($q) { + hb.when('GET').respond(200); + $q.resolve().then(function() { + hb('GET', '/some', null, noop, {}); + }); + + expect(function() { + hb.verifyNoOutstandingRequest(); + }).toThrowError('Unflushed requests: 1\n' + + ' GET /some'); + })); + + + it('should describe multiple unflushed requests', function() { + hb.when('GET').respond(200); + hb.when('PUT').respond(200); + hb('GET', '/some', null, noop, {}); + hb('PUT', '/elsewhere', null, noop, {}); + + expect(function() { + hb.verifyNoOutstandingRequest(); + }).toThrowError('Unflushed requests: 2\n' + + ' GET /some\n' + + ' PUT /elsewhere'); }); }); @@ -1502,13 +2228,68 @@ describe('ngMock', function() { hb[shortcut]('/foo').respond('bar'); hb(method, '/foo', undefined, callback); hb.flush(); - expect(callback).toHaveBeenCalledOnceWith(200, 'bar', '', ''); + expect(callback).toHaveBeenCalledOnceWith(200, 'bar', '', '', 'complete'); }); }); }); }); + describe('expectRoute/whenRoute shortcuts', function() { + angular.forEach(['expectRoute', 'whenRoute'], function(routeShortcut) { + var methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'JSONP']; + they('should provide ' + routeShortcut + ' shortcut with $prop method', methods, + function() { + hb[routeShortcut](this, '/route').respond('path'); + hb(this, '/route', undefined, callback); + hb.flush(); + expect(callback).toHaveBeenCalledOnceWith(200, 'path', '', '', 'complete'); + } + ); + they('should match colon delimited parameters in ' + routeShortcut + ' $prop method', methods, + function() { + hb[routeShortcut](this, '/route/:id/path/:s_id').respond('path'); + hb(this, '/route/123/path/456', undefined, callback); + hb.flush(); + expect(callback).toHaveBeenCalledOnceWith(200, 'path', '', '', 'complete'); + } + ); + they('should ignore query params when matching in ' + routeShortcut + ' $prop method', methods, + function(method) { + angular.forEach([ + {route: '/route1/:id', url: '/route1/Alpha', expectedParams: {id: 'Alpha'}}, + {route: '/route2/:id', url: '/route2/Bravo/?', expectedParams: {id: 'Bravo'}}, + {route: '/route3/:id', url: '/route3/Charlie?q=str&foo=bar', expectedParams: {id: 'Charlie', q: 'str', foo: 'bar'}}, + {route: '/:x/route4', url: '/Delta/route4?q=str&foo=bar', expectedParams: {x: 'Delta', q: 'str', foo: 'bar'}}, + {route: '/route5/:id*', url: '/route5/Echo/456?q=str&foo=bar', expectedParams: {id: 'Echo/456', q: 'str', foo: 'bar'}}, + {route: '/route6/:id*', url: '/route6/Foxtrot/456/?q=str&foo=bar', expectedParams: {id: 'Foxtrot/456', q: 'str', foo: 'bar'}}, + {route: '/route7/:id*', url: '/route7/Golf/456//?q=str&foo=bar', expectedParams: {id: 'Golf/456', q: 'str', foo: 'bar'}}, + {route: '/:x*/route8', url: '/Hotel/123/456/route8/?q=str&foo=bar', expectedParams: {x: 'Hotel/123/456', q: 'str', foo: 'bar'}}, + {route: '/:x*/route9/:id', url: '/India/456/route9/0?q=str&foo=bar', expectedParams: {x: 'India/456', id: '0', q: 'str', foo: 'bar'}}, + {route: '/route10', url: '/route10?q=Juliet&foo=bar', expectedParams: {q: 'Juliet', foo: 'bar'}}, + {route: '/route11', url: '/route11///?q=Kilo', expectedParams: {q: 'Kilo'}}, + {route: '/route12', url: '/route12///', expectedParams: {}} + ], function(testDataEntry) { + callback.calls.reset(); + var paramsSpy = jasmine.createSpy('params'); + hb[routeShortcut](method, testDataEntry.route).respond( + function(method, url, data, headers, params) { + paramsSpy(params); + // status, response, headers, statusText, xhrStatus + return [200, 'path', { 'x-header': 'foo' }, 'OK', 'complete']; + } + ); + hb(method, testDataEntry.url, undefined, callback); + hb.flush(); + expect(callback).toHaveBeenCalledOnceWith(200, 'path', 'x-header: foo', 'OK', 'complete'); + expect(paramsSpy).toHaveBeenCalledOnceWith(testDataEntry.expectedParams); + }); + } + ); + }); + }); + + describe('MockHttpExpectation', function() { /* global MockHttpExpectation */ @@ -1521,6 +2302,11 @@ describe('ngMock', function() { expect(exp.match('GET', 'a/x')).toBe(false); }); + it('should match url with same query params, but different order', function() { + var exp = new MockHttpExpectation('GET', 'www.example.com/x/y?a=b&c=d&e=f'); + + expect(exp.matchUrl('www.example.com/x/y?e=f&c=d&a=b')).toBe(true); + }); it('should accept url as function', function() { var urlValidator = function(url) { @@ -1551,7 +2337,7 @@ describe('ngMock', function() { expect(exp.matchData({})).toBe(false); expect(exp.match('POST', '/url', '{"id": "xxx", "status": "N"}')).toBe(true); - expect(exp.match('POST', '/url', {"id": "xxx", "status": "N"})).toBe(true); + expect(exp.match('POST', '/url', {'id': 'xxx', 'status': 'N'})).toBe(true); }); @@ -1568,7 +2354,7 @@ describe('ngMock', function() { it('should accept headers as function', function() { var exp = new MockHttpExpectation('GET', '/url', undefined, function(h) { - return h['Content-Type'] == 'application/json'; + return h['Content-Type'] === 'application/json'; }); expect(exp.matchHeaders({})).toBe(false); @@ -1582,6 +2368,10 @@ describe('ngMock', function() { it('should create mock application root', inject(function($rootElement) { expect($rootElement.text()).toEqual(''); })); + + it('should attach the `$injector` to `$rootElement`', inject(function($injector, $rootElement) { + expect($rootElement.injector()).toBe($injector); + })); }); @@ -1751,33 +2541,371 @@ describe('ngMock', function() { })); }); }); + + + describe('$controllerDecorator', function() { + + it('should support creating controller with bindings', function() { + var called = false; + var data = [ + { name: 'derp1', id: 0 }, + { name: 'testname', id: 1 }, + { name: 'flurp', id: 2 } + ]; + module(function($controllerProvider) { + $controllerProvider.register('testCtrl', function() { + expect(this.data).toBeUndefined(); + called = true; + }); + }); + inject(function($controller, $rootScope) { + var ctrl = $controller('testCtrl', { scope: $rootScope }, { data: data }); + expect(ctrl.data).toBe(data); + expect(called).toBe(true); + }); + }); + + + it('should support assigning bindings when a value is returned from the constructor', + function() { + var called = false; + var data = [ + { name: 'derp1', id: 0 }, + { name: 'testname', id: 1 }, + { name: 'flurp', id: 2 } + ]; + module(function($controllerProvider) { + $controllerProvider.register('testCtrl', function() { + expect(this.data).toBeUndefined(); + called = true; + return {}; + }); + }); + inject(function($controller, $rootScope) { + var ctrl = $controller('testCtrl', { scope: $rootScope }, { data: data }); + expect(ctrl.data).toBe(data); + expect(called).toBe(true); + }); + } + ); + + + if (support.classes) { + it('should support assigning bindings to class-based controller', function() { + var called = false; + var data = [ + { name: 'derp1', id: 0 }, + { name: 'testname', id: 1 }, + { name: 'flurp', id: 2 } + ]; + module(function($controllerProvider) { + // eslint-disable-next-line no-eval + var TestCtrl = eval('(class { constructor() { called = true; } })'); + $controllerProvider.register('testCtrl', TestCtrl); + }); + inject(function($controller, $rootScope) { + var ctrl = $controller('testCtrl', { scope: $rootScope }, { data: data }); + expect(ctrl.data).toBe(data); + expect(called).toBe(true); + }); + }); + } + }); + + + describe('$componentController', function() { + it('should instantiate a simple controller defined inline in a component', function() { + function TestController($scope, a, b) { + this.$scope = $scope; + this.a = a; + this.b = b; + } + module(function($compileProvider) { + $compileProvider.component('test', { + controller: TestController + }); + }); + inject(function($componentController, $rootScope) { + var $scope = {}; + var ctrl = $componentController('test', { $scope: $scope, a: 'A', b: 'B' }, { x: 'X', y: 'Y' }); + expect(ctrl).toEqual(extend(new TestController($scope, 'A', 'B'), { x: 'X', y: 'Y' })); + expect($scope.$ctrl).toBe(ctrl); + }); + }); + + it('should instantiate a controller with $$inject annotation defined inline in a component', function() { + function TestController(x, y, z) { + this.$scope = x; + this.a = y; + this.b = z; + } + TestController.$inject = ['$scope', 'a', 'b']; + module(function($compileProvider) { + $compileProvider.component('test', { + controller: TestController + }); + }); + inject(function($componentController, $rootScope) { + var $scope = {}; + var ctrl = $componentController('test', { $scope: $scope, a: 'A', b: 'B' }, { x: 'X', y: 'Y' }); + expect(ctrl).toEqual(extend(new TestController($scope, 'A', 'B'), { x: 'X', y: 'Y' })); + expect($scope.$ctrl).toBe(ctrl); + }); + }); + + it('should instantiate a named controller defined in a component', function() { + function TestController($scope, a, b) { + this.$scope = $scope; + this.a = a; + this.b = b; + } + module(function($controllerProvider, $compileProvider) { + $controllerProvider.register('TestController', TestController); + $compileProvider.component('test', { + controller: 'TestController' + }); + }); + inject(function($componentController, $rootScope) { + var $scope = {}; + var ctrl = $componentController('test', { $scope: $scope, a: 'A', b: 'B' }, { x: 'X', y: 'Y' }); + expect(ctrl).toEqual(extend(new TestController($scope, 'A', 'B'), { x: 'X', y: 'Y' })); + expect($scope.$ctrl).toBe(ctrl); + }); + }); + + it('should instantiate a named controller with `controller as` syntax defined in a component', function() { + function TestController($scope, a, b) { + this.$scope = $scope; + this.a = a; + this.b = b; + } + module(function($controllerProvider, $compileProvider) { + $controllerProvider.register('TestController', TestController); + $compileProvider.component('test', { + controller: 'TestController as testCtrl' + }); + }); + inject(function($componentController, $rootScope) { + var $scope = {}; + var ctrl = $componentController('test', { $scope: $scope, a: 'A', b: 'B' }, { x: 'X', y: 'Y' }); + expect(ctrl).toEqual(extend(new TestController($scope, 'A', 'B'), {x: 'X', y: 'Y'})); + expect($scope.testCtrl).toBe(ctrl); + }); + }); + + it('should instantiate the controller of the restrict:\'E\' component if there are more directives with the same name but not restricted to \'E\'', function() { + function TestController() { + this.r = 6779; + } + module(function($compileProvider) { + $compileProvider.directive('test', function() { + return { restrict: 'A' }; + }); + $compileProvider.component('test', { + controller: TestController + }); + }); + inject(function($componentController, $rootScope) { + var ctrl = $componentController('test', { $scope: {} }); + expect(ctrl).toEqual(new TestController()); + }); + }); + + it('should instantiate the controller of the restrict:\'E\' component if there are more directives with the same name and restricted to \'E\' but no controller', function() { + function TestController() { + this.r = 22926; + } + module(function($compileProvider) { + $compileProvider.directive('test', function() { + return { restrict: 'E' }; + }); + $compileProvider.component('test', { + controller: TestController + }); + }); + inject(function($componentController, $rootScope) { + var ctrl = $componentController('test', { $scope: {} }); + expect(ctrl).toEqual(new TestController()); + }); + }); + + it('should instantiate the controller of the directive with controller, controllerAs and restrict:\'E\' if there are more directives', function() { + function TestController() { + this.r = 18842; + } + module(function($compileProvider) { + $compileProvider.directive('test', function() { + return { }; + }); + $compileProvider.directive('test', function() { + return { + restrict: 'E', + controller: TestController, + controllerAs: '$ctrl' + }; + }); + }); + inject(function($componentController, $rootScope) { + var ctrl = $componentController('test', { $scope: {} }); + expect(ctrl).toEqual(new TestController()); + }); + }); + + it('should fail if there is no directive with restrict:\'E\' and controller', function() { + function TestController() { + this.r = 31145; + } + module(function($compileProvider) { + $compileProvider.directive('test', function() { + return { + restrict: 'AC', + controller: TestController + }; + }); + $compileProvider.directive('test', function() { + return { + restrict: 'E', + controller: TestController + }; + }); + $compileProvider.directive('test', function() { + return { + restrict: 'EA', + controller: TestController, + controllerAs: '$ctrl' + }; + }); + $compileProvider.directive('test', function() { + return { restrict: 'E' }; + }); + }); + inject(function($componentController, $rootScope) { + expect(function() { + $componentController('test', { $scope: {} }); + }).toThrowError('No component found'); + }); + }); + + it('should fail if there more than two components with same name', function() { + function TestController($scope, a, b) { + this.$scope = $scope; + this.a = a; + this.b = b; + } + module(function($compileProvider) { + $compileProvider.directive('test', function() { + return { + restrict: 'E', + controller: TestController, + controllerAs: '$ctrl' + }; + }); + $compileProvider.component('test', { + controller: TestController + }); + }); + inject(function($componentController, $rootScope) { + expect(function() { + var $scope = {}; + $componentController('test', { $scope: $scope, a: 'A', b: 'B' }, { x: 'X', y: 'Y' }); + }).toThrowError('Too many components found'); + }); + }); + + it('should create an isolated child of $rootScope, if no `$scope` local is provided', function() { + function TestController($scope) { + this.$scope = $scope; + } + module(function($compileProvider) { + $compileProvider.component('test', { + controller: TestController + }); + }); + inject(function($componentController, $rootScope) { + var $ctrl = $componentController('test'); + expect($ctrl.$scope).toBeDefined(); + expect($ctrl.$scope.$parent).toBe($rootScope); + // check it is isolated + $rootScope.a = 17; + expect($ctrl.$scope.a).toBeUndefined(); + $ctrl.$scope.a = 42; + expect($rootScope.a).toEqual(17); + }); + }); + }); }); describe('ngMockE2E', function() { + + var noop = angular.noop; + var extend = angular.extend; + describe('$httpBackend', function() { - var hb, realHttpBackend, callback; + var hb, realHttpBackend, realHttpBackendBrowser, $http, callback; beforeEach(function() { - module(function($provide) { - callback = jasmine.createSpy('callback'); + callback = jasmine.createSpy('callback'); + angular.module('ng').config(function($provide) { realHttpBackend = jasmine.createSpy('real $httpBackend'); - $provide.value('$httpBackend', realHttpBackend); - $provide.decorator('$httpBackend', angular.mock.e2e.$httpBackendDecorator); + $provide.factory('$httpBackend', ['$browser', function($browser) { + return realHttpBackend.and.callFake(function() { realHttpBackendBrowser = $browser; }); + }]); }); + module('ngMockE2E'); inject(function($injector) { hb = $injector.get('$httpBackend'); + $http = $injector.get('$http'); }); }); + it('should throw error when unexpected request - without error callback', function() { + expect(function() { + $http.get('/some').then(noop); + + hb.verifyNoOutstandingRequest(); + }).toThrowError('Unexpected request: GET /some\nNo more request expected'); + }); + + + it('should throw error when unexpected request - with error callback', function() { + expect(function() { + $http.get('/some').then(noop, noop); + + hb.verifyNoOutstandingRequest(); + }).toThrowError('Unexpected request: GET /some\nNo more request expected'); + }); + + it('should throw error when expectation fails - without error callback', function() { + expect(function() { + hb.expectPOST('/some', { foo: 1 }).respond({}); + $http.post('/some', { foo: 2 }).then(noop); + + hb.flush(); + }).toThrowError(/^Expected POST \/some with different data/); + }); + + it('should throw error when unexpected request - with error callback', function() { + expect(function() { + hb.expectPOST('/some', { foo: 1 }).respond({}); + $http.post('/some', { foo: 2 }).then(noop, noop); + + hb.flush(); + }).toThrowError(/^Expected POST \/some with different data/); + }); + + describe('passThrough()', function() { it('should delegate requests to the real backend when passThrough is invoked', function() { + var eventHandlers = {progress: angular.noop}; + var uploadEventHandlers = {progress: angular.noop}; + hb.when('GET', /\/passThrough\/.*/).passThrough(); - hb('GET', '/passThrough/23', null, callback, {}, null, true); + hb('GET', '/passThrough/23', null, callback, {}, null, true, 'blob', eventHandlers, uploadEventHandlers); expect(realHttpBackend).toHaveBeenCalledOnceWith( - 'GET', '/passThrough/23', null, callback, {}, null, true); + 'GET', '/passThrough/23', null, callback, {}, null, true, 'blob', eventHandlers, uploadEventHandlers); }); it('should be able to override a respond definition with passThrough', function() { @@ -1786,7 +2914,7 @@ describe('ngMockE2E', function() { hb('GET', '/passThrough/23', null, callback, {}, null, true); expect(realHttpBackend).toHaveBeenCalledOnceWith( - 'GET', '/passThrough/23', null, callback, {}, null, true); + 'GET', '/passThrough/23', null, callback, {}, null, true, undefined, undefined, undefined); }); it('should be able to override a respond definition with passThrough', inject(function($browser) { @@ -1796,7 +2924,15 @@ describe('ngMockE2E', function() { $browser.defer.flush(); expect(realHttpBackend).not.toHaveBeenCalled(); - expect(callback).toHaveBeenCalledOnceWith(200, 'passThrough override', '', ''); + expect(callback).toHaveBeenCalledOnceWith(200, 'passThrough override', '', '', 'complete'); + })); + + it('should pass through to an httpBackend that uses the same $browser service', inject(function($browser) { + hb.when('GET', /\/passThrough\/.*/).passThrough(); + hb('GET', '/passThrough/23'); + + expect(realHttpBackend).toHaveBeenCalledOnce(); + expect(realHttpBackendBrowser).toBe($browser); })); }); @@ -1812,11 +2948,697 @@ describe('ngMockE2E', function() { })); }); }); + + describe('ngAnimateMock', function() { + + beforeEach(module('ngAnimate')); + beforeEach(module('ngAnimateMock')); + + var ss, element, trackedAnimations, animationLog; + + afterEach(function() { + if (element) { + element.remove(); + } + if (ss) { + ss.destroy(); + } + }); + + beforeEach(module(function($animateProvider) { + trackedAnimations = []; + animationLog = []; + + $animateProvider.register('.animate', function() { + return { + leave: logFn('leave'), + addClass: logFn('addClass') + }; + + function logFn(method) { + return function(element) { + animationLog.push('start ' + method); + trackedAnimations.push(getDoneCallback(arguments)); + + return function closingFn(cancel) { + var lab = cancel ? 'cancel' : 'end'; + animationLog.push(lab + ' ' + method); + }; + }; + } + + function getDoneCallback(args) { + for (var i = args.length; i > 0; i--) { + if (angular.isFunction(args[i])) return args[i]; + } + } + }); + + return function($animate, $rootElement, $document, $rootScope) { + ss = createMockStyleSheet($document); + + element = angular.element('
          '); + $rootElement.append(element); + angular.element($document[0].body).append($rootElement); + $animate.enabled(true); + $rootScope.$digest(); + }; + })); + + describe('$animate.queue', function() { + it('should maintain a queue of the executed animations', inject(function($animate) { + element.removeClass('animate'); // we don't care to test any actual animations + var options = {}; + + $animate.addClass(element, 'on', options); + var first = $animate.queue[0]; + expect(first.element).toBe(element); + expect(first.event).toBe('addClass'); + expect(first.options).toBe(options); + + $animate.removeClass(element, 'off', options); + var second = $animate.queue[1]; + expect(second.element).toBe(element); + expect(second.event).toBe('removeClass'); + expect(second.options).toBe(options); + + $animate.leave(element, options); + var third = $animate.queue[2]; + expect(third.element).toBe(element); + expect(third.event).toBe('leave'); + expect(third.options).toBe(options); + })); + }); + + describe('$animate.flush()', function() { + it('should throw an error if there is nothing to animate', inject(function($animate) { + expect(function() { + $animate.flush(); + }).toThrowError('No pending animations ready to be closed or flushed'); + })); + + it('should trigger the animation to start', + inject(function($animate) { + + expect(trackedAnimations.length).toBe(0); + $animate.leave(element); + $animate.flush(); + expect(trackedAnimations.length).toBe(1); + })); + + it('should trigger the animation to end once run and called', + inject(function($animate) { + + $animate.leave(element); + $animate.flush(); + expect(element.parent().length).toBe(1); + + trackedAnimations[0](); + $animate.flush(); + expect(element.parent().length).toBe(0); + })); + + it('should trigger the animation promise callback to fire once run and closed', + inject(function($animate) { + + var doneSpy = jasmine.createSpy(); + $animate.leave(element).then(doneSpy); + $animate.flush(); + + trackedAnimations[0](); + expect(doneSpy).not.toHaveBeenCalled(); + $animate.flush(); + expect(doneSpy).toHaveBeenCalled(); + })); + + it('should trigger a series of CSS animations to trigger and start once run', + inject(function($animate, $rootScope) { + + if (!browserSupportsCssAnimations()) return; + + ss.addRule('.leave-me.ng-leave', 'transition:1s linear all;'); + + var i, elm, elms = []; + for (i = 0; i < 5; i++) { + elm = angular.element('
          '); + element.append(elm); + elms.push(elm); + + $animate.leave(elm); + } + + $rootScope.$digest(); + + for (i = 0; i < 5; i++) { + elm = elms[i]; + expect(elm.hasClass('ng-leave')).toBe(true); + expect(elm.hasClass('ng-leave-active')).toBe(false); + } + + $animate.flush(); + + for (i = 0; i < 5; i++) { + elm = elms[i]; + expect(elm.hasClass('ng-leave')).toBe(true); + expect(elm.hasClass('ng-leave-active')).toBe(true); + } + })); + + it('should trigger parent and child animations to run within the same flush', + inject(function($animate, $rootScope) { + + var child = angular.element('
          '); + element.append(child); + + expect(trackedAnimations.length).toBe(0); + + $animate.addClass(element, 'go'); + $animate.addClass(child, 'start'); + $animate.flush(); + + expect(trackedAnimations.length).toBe(2); + })); + + it('should trigger animation callbacks when called', + inject(function($animate, $rootScope) { + + var spy = jasmine.createSpy(); + $animate.on('addClass', element, spy); + + $animate.addClass(element, 'on'); + expect(spy).not.toHaveBeenCalled(); + + $animate.flush(); + expect(spy).toHaveBeenCalledTimes(1); + + trackedAnimations[0](); + $animate.flush(); + expect(spy).toHaveBeenCalledTimes(2); + })); + }); + + describe('$animate.closeAndFlush()', function() { + it('should close the currently running $animateCss animations', + inject(function($animateCss, $animate) { + + if (!browserSupportsCssAnimations()) return; + + var spy = jasmine.createSpy(); + var runner = $animateCss(element, { + duration: 1, + to: { color: 'red' } + }).start(); + + runner.then(spy); + + expect(spy).not.toHaveBeenCalled(); + $animate.closeAndFlush(); + expect(spy).toHaveBeenCalled(); + })); + + it('should close the currently running $$animateJs animations', + inject(function($$animateJs, $animate) { + + var spy = jasmine.createSpy(); + var runner = $$animateJs(element, 'leave', 'animate', {}).start(); + runner.then(spy); + + expect(spy).not.toHaveBeenCalled(); + $animate.closeAndFlush(); + expect(spy).toHaveBeenCalled(); + })); + + it('should run the closing javascript animation function upon flush', + inject(function($$animateJs, $animate) { + + $$animateJs(element, 'leave', 'animate', {}).start(); + + expect(animationLog).toEqual(['start leave']); + $animate.closeAndFlush(); + expect(animationLog).toEqual(['start leave', 'end leave']); + })); + + it('should not throw when a regular animation has no javascript animation', + inject(function($animate, $$animation, $rootElement) { + + if (!browserSupportsCssAnimations()) return; + + var element = angular.element('
          '); + $rootElement.append(element); + + // Make sure the animation has valid $animateCss options + $$animation(element, null, { + from: { background: 'red' }, + to: { background: 'blue' }, + duration: 1, + transitionStyle: 'all 1s' + }); + + expect(function() { + $animate.closeAndFlush(); + }).not.toThrow(); + + dealoc(element); + })); + + it('should throw an error if there are no animations to close and flush', + inject(function($animate) { + + expect(function() { + $animate.closeAndFlush(); + }).toThrowError('No pending animations ready to be closed or flushed'); + + })); + }); + }); }); + describe('make sure that we can create an injector outside of tests', function() { //since some libraries create custom injectors outside of tests, //we want to make sure that this is not breaking the internals of //how we manage annotated function cleanup during tests. See #10967 angular.injector([function($injector) {}]); }); + + +describe('`afterEach` clean-up', function() { + describe('`$rootElement`', function() { + + describe('undecorated', function() { + var prevRootElement; + var prevCleanDataSpy; + + + it('should set up spies for the next test to verify that `$rootElement` was cleaned up', + function() { + module(function($provide) { + $provide.decorator('$rootElement', function($delegate) { + prevRootElement = $delegate; + + // Spy on `angular.element.cleanData()`, so the next test can verify + // that it has been called as necessary + prevCleanDataSpy = spyOn(angular.element, 'cleanData').and.callThrough(); + + return $delegate; + }); + }); + + // Inject the `$rootElement` to ensure it has been created + inject(function($rootElement) { + expect($rootElement.injector()).toBeDefined(); + }); + } + ); + + + it('should clean up `$rootElement` after each test', function() { + // One call is made by `testabilityPatch`'s `dealoc()` + // We want to verify the subsequent call, made by `angular-mocks` + expect(prevCleanDataSpy).toHaveBeenCalledTimes(2); + + var cleanUpNodes = prevCleanDataSpy.calls.argsFor(1)[0]; + expect(cleanUpNodes.length).toBe(1); + expect(cleanUpNodes[0]).toBe(prevRootElement[0]); + }); + }); + + + describe('decorated', function() { + var prevOriginalRootElement; + var prevRootElement; + var prevCleanDataSpy; + + + it('should set up spies for the next text to verify that `$rootElement` was cleaned up', + function() { + module(function($provide) { + $provide.decorator('$rootElement', function($delegate) { + prevOriginalRootElement = $delegate; + + // Mock `$rootElement` to be able to verify that the correct object is cleaned up + prevRootElement = angular.element('
          '); + + // Spy on `angular.element.cleanData()`, so the next test can verify + // that it has been called as necessary + prevCleanDataSpy = spyOn(angular.element, 'cleanData').and.callThrough(); + + return prevRootElement; + }); + }); + + // Inject the `$rootElement` to ensure it has been created + inject(function($rootElement) { + expect($rootElement).toBe(prevRootElement); + expect(prevOriginalRootElement.injector()).toBeDefined(); + expect(prevRootElement.injector()).toBeUndefined(); + + // If we don't clean up `prevOriginalRootElement`-related data now, `testabilityPatch` will + // complain about a memory leak, because it doesn't clean up after the original + // `$rootElement` + // This is a false alarm, because `angular-mocks` would have cleaned up in a subsequent + // `afterEach` block + prevOriginalRootElement.removeData(); + }); + } + ); + + + it('should clean up `$rootElement` (both original and decorated) after each test', + function() { + // One call is made by `testabilityPatch`'s `dealoc()` + // We want to verify the subsequent call, made by `angular-mocks` + expect(prevCleanDataSpy).toHaveBeenCalledTimes(2); + + var cleanUpNodes = prevCleanDataSpy.calls.argsFor(1)[0]; + expect(cleanUpNodes.length).toBe(2); + expect(cleanUpNodes[0]).toBe(prevOriginalRootElement[0]); + expect(cleanUpNodes[1]).toBe(prevRootElement[0]); + } + ); + }); + + + describe('uninstantiated or falsy', function() { + it('should not break if `$rootElement` was never instantiated', function() { + // Just an empty test to verify that `angular-mocks` doesn't break, + // when trying to clean up `$rootElement`, if `$rootElement` was never injected in the test + // (and thus never instantiated/created) + + // Ensure the `$injector` is created - if there is no `$injector`, no clean-up takes places + inject(function() {}); + }); + + + it('should not break if the decorated `$rootElement` is falsy (e.g. `null`)', function() { + module({$rootElement: null}); + + // Ensure the `$injector` is created - if there is no `$injector`, no clean-up takes places + inject(function() {}); + }); + }); + }); + + + describe('`$rootScope`', function() { + describe('undecorated', function() { + var prevRootScope; + var prevDestroySpy; + + + it('should set up spies for the next test to verify that `$rootScope` was cleaned up', + inject(function($rootScope) { + prevRootScope = $rootScope; + prevDestroySpy = spyOn($rootScope, '$destroy').and.callThrough(); + }) + ); + + + it('should clean up `$rootScope` after each test', inject(function($rootScope) { + expect($rootScope).not.toBe(prevRootScope); + expect(prevDestroySpy).toHaveBeenCalledOnce(); + expect(prevRootScope.$$destroyed).toBe(true); + })); + }); + + + describe('falsy or without `$destroy()` method', function() { + it('should not break if `$rootScope` is falsy (e.g. `null`)', function() { + // Just an empty test to verify that `angular-mocks` doesn't break, + // when trying to clean up a mocked `$rootScope` set to `null` + + module({$rootScope: null}); + + // Ensure the `$injector` is created - if there is no `$injector`, no clean-up takes places + inject(function() {}); + }); + + + it('should not break if `$rootScope.$destroy` is not a function', function() { + // Just an empty test to verify that `angular-mocks` doesn't break, + // when trying to clean up a mocked `$rootScope` without a `$destroy()` method + + module({$rootScope: {}}); + + // Ensure the `$injector` is created - if there is no `$injector`, no clean-up takes places + inject(function() {}); + }); + }); + }); +}); + + +describe('sharedInjector', function() { + // this is of a bit tricky feature to test as we hit angular's own testing + // mechanisms (e.g around jQuery cache checking), as ngMock augments the very + // jasmine test runner we're using to test ngMock! + // + // with that in mind, we define a stubbed test framework + // to simulate test cases being run with the ngMock hooks + + + // we use the 'module' and 'inject' globals from ngMock + + it('allows me to mutate a single instance of a module (proving it has been shared)', ngMockTest(function() { + sdescribe('test state is shared', function() { + angular.module('sharedInjectorTestModuleA', []) + .factory('testService', function() { + return { state: 0 }; + }); + + module.sharedInjector(); + + sbeforeAll(module('sharedInjectorTestModuleA')); + + sit('access and mutate', inject(function(testService) { + testService.state += 1; + })); + + sit('expect mutation to have persisted', inject(function(testService) { + expect(testService.state).toEqual(1); + })); + }); + })); + + + it('works with standard beforeEach', ngMockTest(function() { + sdescribe('test state is not shared', function() { + angular.module('sharedInjectorTestModuleC', []) + .factory('testService', function() { + return { state: 0 }; + }); + + sbeforeEach(module('sharedInjectorTestModuleC')); + + sit('access and mutate', inject(function(testService) { + testService.state += 1; + })); + + sit('expect mutation not to have persisted', inject(function(testService) { + expect(testService.state).toEqual(0); + })); + }); + })); + + + it('allows me to stub with shared injector', ngMockTest(function() { + sdescribe('test state is shared', function() { + angular.module('sharedInjectorTestModuleD', []) + .value('testService', 43); + + module.sharedInjector(); + + sbeforeAll(module('sharedInjectorTestModuleD', function($provide) { + $provide.value('testService', 42); + })); + + sit('expected access stubbed value', inject(function(testService) { + expect(testService).toEqual(42); + })); + }); + })); + + it('doesn\'t interfere with other test describes', ngMockTest(function() { + angular.module('sharedInjectorTestModuleE', []) + .factory('testService', function() { + return { state: 0 }; + }); + + sdescribe('with stubbed injector', function() { + + module.sharedInjector(); + + sbeforeAll(module('sharedInjectorTestModuleE')); + + sit('access and mutate', inject(function(testService) { + expect(testService.state).toEqual(0); + testService.state += 1; + })); + + sit('expect mutation to have persisted', inject(function(testService) { + expect(testService.state).toEqual(1); + })); + }); + + sdescribe('without stubbed injector', function() { + sbeforeEach(module('sharedInjectorTestModuleE')); + + sit('access and mutate', inject(function(testService) { + expect(testService.state).toEqual(0); + testService.state += 1; + })); + + sit('expect original, unmutated value', inject(function(testService) { + expect(testService.state).toEqual(0); + })); + }); + })); + + it('prevents nested use of sharedInjector()', function() { + var test = ngMockTest(function() { + sdescribe('outer', function() { + + module.sharedInjector(); + + sdescribe('inner', function() { + + module.sharedInjector(); + + sit('should not get here', function() { + throw Error('should have thrown before here!'); + }); + }); + + }); + + }); + + assertThrowsErrorMatching(test.bind(this), /already called sharedInjector()/); + }); + + it('warns that shared injector cannot be used unless test frameworks define before/after all hooks', function() { + assertThrowsErrorMatching(function() { + module.sharedInjector(); + }, /sharedInjector()/); + }); + + function assertThrowsErrorMatching(fn, re) { + try { + fn(); + } catch (e) { + if (re.test(e.message)) { + return; + } + throw Error('thrown error \'' + e.message + '\' did not match:' + re); + } + throw Error('should have thrown error'); + } + + // run a set of test cases in the sdescribe stub test framework + function ngMockTest(define) { + return function() { + var spec = this; + module.$$currentSpec(null); + + // configure our stubbed test framework and then hook ngMock into it + // in much the same way + module.$$beforeAllHook = sbeforeAll; + module.$$afterAllHook = safterAll; + + sdescribe.root = sdescribe('root', function() {}); + + sdescribe.root.beforeEach.push(module.$$beforeEach); + sdescribe.root.afterEach.push(module.$$afterEach); + + try { + define(); + sdescribe.root.run(); + } finally { + // clear up + module.$$beforeAllHook = null; + module.$$afterAllHook = null; + module.$$currentSpec(spec); + } + }; + } + + // stub test framework that follows the pattern of hooks that + // jasmine/mocha do + function sdescribe(name, define) { + var self = { name: name }; + self.parent = sdescribe.current || sdescribe.root; + if (self.parent) { + self.parent.describes.push(self); + } + + var previous = sdescribe.current; + sdescribe.current = self; + + self.beforeAll = []; + self.beforeEach = []; + self.afterAll = []; + self.afterEach = []; + self.define = define; + self.tests = []; + self.describes = []; + + self.run = function() { + var spec = {}; + self.hooks('beforeAll', spec); + + self.tests.forEach(function(test) { + if (self.parent) self.parent.hooks('beforeEach', spec); + self.hooks('beforeEach', spec); + test.run.call(spec); + self.hooks('afterEach', spec); + if (self.parent) self.parent.hooks('afterEach', spec); + }); + + self.describes.forEach(function(d) { + d.run(); + }); + + self.hooks('afterAll', spec); + }; + + self.hooks = function(hook, spec) { + self[hook].forEach(function(f) { + f.call(spec); + }); + }; + + define(); + + sdescribe.current = previous; + + return self; + } + + function sit(name, fn) { + if (typeof fn !== 'function') throw Error('not fn', fn); + sdescribe.current.tests.push({ + name: name, + run: fn + }); + } + + function sbeforeAll(fn) { + if (typeof fn !== 'function') throw Error('not fn', fn); + sdescribe.current.beforeAll.push(fn); + } + + function safterAll(fn) { + if (typeof fn !== 'function') throw Error('not fn', fn); + sdescribe.current.afterAll.push(fn); + } + + function sbeforeEach(fn) { + if (typeof fn !== 'function') throw Error('not fn', fn); + sdescribe.current.beforeEach.push(fn); + } + + function safterEach(fn) { + if (typeof fn !== 'function') throw Error('not fn', fn); + sdescribe.current.afterEach.push(fn); + } +}); diff --git a/test/ngResource/resourceSpec.js b/test/ngResource/resourceSpec.js index ba72525e6de9..bfdac9c0b289 100644 --- a/test/ngResource/resourceSpec.js +++ b/test/ngResource/resourceSpec.js @@ -1,7 +1,11 @@ 'use strict'; -describe("resource", function() { - var $resource, CreditCard, callback, $httpBackend, resourceProvider; +describe('resource', function() { + var noop = angular.noop; + var extend = angular.extend; + +describe('basic usage', function() { + var $resource, CreditCard, callback, $httpBackend, resourceProvider, $q; beforeEach(module('ngResource')); @@ -12,6 +16,7 @@ describe("resource", function() { beforeEach(inject(function($injector) { $httpBackend = $injector.get('$httpBackend'); $resource = $injector.get('$resource'); + $q = $injector.get('$q'); CreditCard = $resource('/CreditCard/:id:verb', {id:'@id.key'}, { charge:{ method:'post', @@ -28,14 +33,14 @@ describe("resource", function() { } }); - callback = jasmine.createSpy(); + callback = jasmine.createSpy('callback'); })); - afterEach(function() { $httpBackend.verifyNoOutstandingExpectation(); }); + describe('isValidDottedPath', function() { /* global isValidDottedPath: false */ it('should support arbitrary dotted names', function() { @@ -44,6 +49,7 @@ describe("resource", function() { expect(isValidDottedPath('1abc')).toBe(false); expect(isValidDottedPath('.')).toBe(false); expect(isValidDottedPath('$')).toBe(true); + expect(isValidDottedPath('@')).toBe(true); expect(isValidDottedPath('a')).toBe(true); expect(isValidDottedPath('A')).toBe(true); expect(isValidDottedPath('a1')).toBe(true); @@ -53,12 +59,14 @@ describe("resource", function() { expect(isValidDottedPath('$.$')).toBe(true); expect(isValidDottedPath('.$')).toBe(false); expect(isValidDottedPath('$.')).toBe(false); + expect(isValidDottedPath('@.')).toBe(false); + expect(isValidDottedPath('.@')).toBe(false); }); }); describe('lookupDottedPath', function() { /* global lookupDottedPath: false */ - var data = {a: {b: 'foo', c: null}}; + var data = {a: {b: 'foo', c: null, '@d':'d-foo'},'@b':'b-foo'}; it('should throw for invalid path', function() { expect(function() { @@ -68,16 +76,18 @@ describe("resource", function() { }); it('should get dotted paths', function() { - expect(lookupDottedPath(data, 'a')).toEqual({b: 'foo', c: null}); + expect(lookupDottedPath(data, 'a')).toEqual({b: 'foo', c: null, '@d':'d-foo'}); expect(lookupDottedPath(data, 'a.b')).toBe('foo'); expect(lookupDottedPath(data, 'a.c')).toBeNull(); + expect(lookupDottedPath(data, 'a.@d')).toBe('d-foo'); + expect(lookupDottedPath(data, '@b')).toBe('b-foo'); }); it('should skip over null/undefined members', function() { - expect(lookupDottedPath(data, 'a.b.c')).toBe(undefined); - expect(lookupDottedPath(data, 'a.c.c')).toBe(undefined); - expect(lookupDottedPath(data, 'a.b.c.d')).toBe(undefined); - expect(lookupDottedPath(data, 'NOT_EXIST')).toBe(undefined); + expect(lookupDottedPath(data, 'a.b.c')).toBeUndefined(); + expect(lookupDottedPath(data, 'a.c.c')).toBeUndefined(); + expect(lookupDottedPath(data, 'a.b.c.d')).toBeUndefined(); + expect(lookupDottedPath(data, 'NOT_EXIST')).toBeUndefined(); }); }); @@ -90,8 +100,78 @@ describe("resource", function() { $httpBackend.flush(); }); + it('should include a request body when calling custom method with hasBody is true', function() { + var instant = {name: 'info.txt'}; + var condition = {at: '2038-01-19 03:14:08'}; + + $httpBackend.expect('CREATE', '/fooresource', instant).respond({fid: 42}); + $httpBackend.expect('DELETE', '/fooresource', condition).respond({}); + + var r = $resource('/fooresource', {}, { + create: {method: 'CREATE', hasBody: true}, + delete: {method: 'DELETE', hasBody: true} + }); + + var creationResponse = r.create(instant); + var deleteResponse = r.delete(condition); + + $httpBackend.flush(); + + expect(creationResponse.fid).toBe(42); + expect(deleteResponse.$resolved).toBe(true); + }); + + it('should not include a request body if hasBody is false on POST, PUT and PATCH', function() { + function verifyRequest(method, url, data) { + expect(data).toBeUndefined(); + return [200, {id: 42}]; + } + + $httpBackend.expect('POST', '/foo').respond(verifyRequest); + $httpBackend.expect('PUT', '/foo').respond(verifyRequest); + $httpBackend.expect('PATCH', '/foo').respond(verifyRequest); + + var R = $resource('/foo', {}, { + post: {method: 'POST', hasBody: false}, + put: {method: 'PUT', hasBody: false}, + patch: {method: 'PATCH', hasBody: false} + }); + + var postResponse = R.post(); + var putResponse = R.put(); + var patchResponse = R.patch(); + + $httpBackend.flush(); + + expect(postResponse.id).toBe(42); + expect(putResponse.id).toBe(42); + expect(patchResponse.id).toBe(42); + }); + + it('should expect a body if hasBody is true', function() { + var username = 'yathos'; + var loginRequest = {name: username, password: 'Smile'}; + var user = {id: 1, name: username}; + + $httpBackend.expect('LOGIN', '/user/me', loginRequest).respond(user); + + $httpBackend.expect('LOGOUT', '/user/me', null).respond(null); - it("should build resource", function() { + var UserService = $resource('/user/me', {}, { + login: {method: 'LOGIN', hasBody: true}, + logout: {method: 'LOGOUT', hasBody: false} + }); + + var loginResponse = UserService.login(loginRequest); + var logoutResponse = UserService.logout(); + + $httpBackend.flush(); + + expect(loginResponse.id).toBe(user.id); + expect(logoutResponse.$resolved).toBe(true); + }); + + it('should build resource', function() { expect(typeof CreditCard).toBe('function'); expect(typeof CreditCard.get).toBe('function'); expect(typeof CreditCard.save).toBe('function'); @@ -133,25 +213,42 @@ describe("resource", function() { it('should omit properties from prototype chain', function() { var original, clone = {}; function Func() {} - Func.prototype.hello = "world"; + Func.prototype.hello = 'world'; original = new Func(); - original.goodbye = "world"; + original.goodbye = 'world'; expect(shallowClearAndCopy(original, clone)).toBe(clone); expect(clone.hello).toBeUndefined(); - expect(clone.goodbye).toBe("world"); + expect(clone.goodbye).toBe('world'); }); }); + it('should not throw if response.data is the resource object', function() { + var data = {id:{key:123}, number:'9876'}; + $httpBackend.expect('GET', '/CreditCard/123').respond(data); + + var cc = CreditCard.get({id:123}); + $httpBackend.flush(); + expect(cc instanceof CreditCard).toBe(true); + + $httpBackend.expect('POST', '/CreditCard/123', angular.toJson(data)).respond(cc); + + cc.$save(); + $httpBackend.flush(); + expect(cc.id).toEqual({key:123}); + expect(cc.number).toEqual('9876'); + }); + + it('should default to empty parameters', function() { $httpBackend.expect('GET', 'URL').respond({}); $resource('URL').query(); }); - it('should ignore slashes of undefinend parameters', function() { + it('should ignore slashes of undefined parameters', function() { var R = $resource('/Path/:a/:b/:c'); $httpBackend.when('GET', '/Path').respond('{}'); @@ -176,7 +273,7 @@ describe("resource", function() { R.get({a:6, b:7, c:8}); }); - it('should not ignore leading slashes of undefinend parameters that have non-slash trailing sequence', function() { + it('should not ignore leading slashes of undefined parameters that have non-slash trailing sequence', function() { var R = $resource('/Path/:a.foo/:b.bar/:c.baz'); $httpBackend.when('GET', '/Path/.foo/.bar.baz').respond('{}'); @@ -230,14 +327,18 @@ describe("resource", function() { $httpBackend.expect('GET', '/Path/foo%231').respond('{}'); $httpBackend.expect('GET', '/Path/doh!@foo?bar=baz%231').respond('{}'); $httpBackend.expect('GET', '/Path/herp$').respond('{}'); + $httpBackend.expect('GET', '/Path/foo;bar').respond('{}'); + $httpBackend.expect('GET', '/Path/foo?bar=baz;qux').respond('{}'); R.get({a: 'foo#1'}); R.get({a: 'doh!@foo', bar: 'baz#1'}); R.get({a: 'herp$'}); + R.get({a: 'foo;bar'}); + R.get({a: 'foo', bar: 'baz;qux'}); }); it('should not encode @ in url params', function() { - //encodeURIComponent is too agressive and doesn't follow http://www.ietf.org/rfc/rfc3986.txt + //encodeURIComponent is too aggressive and doesn't follow http://www.ietf.org/rfc/rfc3986.txt //with regards to the character set (pchar) allowed in path segments //so we need this test to make sure that we don't over-encode the params and break stuff like //buzz api which uses @self @@ -292,6 +393,38 @@ describe("resource", function() { R.get({a: 'foo'}); }); + it('should support IPv6 URLs', function() { + test('http://[2620:0:861:ed1a::1]/', {ed1a: 'foo'}, 'http://[2620:0:861:ed1a::1]/'); + test('http://[2620:0:861:ed1a::1]/', {ed1a: 'foo'}, 'http://[2620:0:861:ed1a::1]/'); + test('http://[2620:0:861:ed1a::1]/:ed1a', {ed1a: 'foo'}, 'http://[2620:0:861:ed1a::1]/foo'); + test('http://[2620:0:861:ed1a::1]/:ed1a', {}, 'http://[2620:0:861:ed1a::1]/'); + test('http://[2620:0:861:ed1a::1]/:ed1a/', {ed1a: 'foo'}, 'http://[2620:0:861:ed1a::1]/foo/'); + test('http://[2620:0:861:ed1a::1]/:ed1a/', {}, 'http://[2620:0:861:ed1a::1]/'); + + // Helpers + function test(templateUrl, params, actualUrl) { + var R = $resource(templateUrl, null, null, {stripTrailingSlashes: false}); + $httpBackend.expect('GET', actualUrl).respond(null); + R.get(params); + } + }); + + it('should support params in the `hostname` part of the URL', function() { + test('http://:hostname', {hostname: 'foo.com'}, 'http://foo.com/'); + test('http://:hostname/', {hostname: 'foo.com'}, 'http://foo.com/'); + test('http://:l2Domain.:l1Domain', {l1Domain: 'com', l2Domain: 'bar'}, 'http://bar.com/'); + test('http://:l2Domain.:l1Domain/', {l1Domain: 'com', l2Domain: 'bar'}, 'http://bar.com/'); + test('http://127.0.0.:octet', {octet: 42}, 'http://127.0.0.42/'); + test('http://127.0.0.:octet/', {octet: 42}, 'http://127.0.0.42/'); + + // Helpers + function test(templateUrl, params, actualUrl) { + var R = $resource(templateUrl, null, null, {stripTrailingSlashes: false}); + $httpBackend.expect('GET', actualUrl).respond(null); + R.get(params); + } + }); + it('should support overriding provider default trailing-slash stripping configuration', function() { // Set the new behavior for all new resources created by overriding the // provider configuration @@ -318,10 +451,18 @@ describe("resource", function() { }); - it('should encode & in url params', function() { - var R = $resource('/Path/:a'); + it('should encode & in query params unless in query param value', function() { + var R1 = $resource('/Path/:a'); $httpBackend.expect('GET', '/Path/doh&foo?bar=baz%261').respond('{}'); - R.get({a: 'doh&foo', bar: 'baz&1'}); + R1.get({a: 'doh&foo', bar: 'baz&1'}); + + var R2 = $resource('/api/myapp/resource?:query'); + $httpBackend.expect('GET', '/api/myapp/resource?foo&bar').respond('{}'); + R2.get({query: 'foo&bar'}); + + var R3 = $resource('/api/myapp/resource?from=:from'); + $httpBackend.expect('GET', '/api/myapp/resource?from=bar%20%26%20blanks').respond('{}'); + R3.get({from: 'bar & blanks'}); }); @@ -359,7 +500,7 @@ describe("resource", function() { }); - it("should build resource with action default param overriding default param", function() { + it('should build resource with action default param overriding default param', function() { $httpBackend.expect('GET', '/Customer/123').respond({id: 'abc'}); var TypeItem = $resource('/:type/:typeId', {type: 'Order'}, {get: {method: 'GET', params: {type: 'Customer'}}}); @@ -404,11 +545,11 @@ describe("resource", function() { it('should throw an exception if a param is called "hasOwnProperty"', function() { expect(function() { $resource('/:hasOwnProperty').get(); - }).toThrowMinErr('$resource','badname', "hasOwnProperty is not a valid parameter name"); + }).toThrowMinErr('$resource','badname', 'hasOwnProperty is not a valid parameter name'); }); - it("should create resource", function() { + it('should create resource', function() { $httpBackend.expect('POST', '/CreditCard', '{"name":"misko"}').respond({id: 123, name: 'misko'}); var cc = CreditCard.save({name: 'misko'}, callback); @@ -418,12 +559,12 @@ describe("resource", function() { $httpBackend.flush(); expect(cc).toEqualData({id: 123, name: 'misko'}); expect(callback).toHaveBeenCalledOnce(); - expect(callback.mostRecentCall.args[0]).toEqual(cc); - expect(callback.mostRecentCall.args[1]()).toEqual({}); + expect(callback.calls.mostRecent().args[0]).toEqual(cc); + expect(callback.calls.mostRecent().args[1]()).toEqual(Object.create(null)); }); - it("should read resource", function() { + it('should read resource', function() { $httpBackend.expect('GET', '/CreditCard/123').respond({id: 123, number: '9876'}); var cc = CreditCard.get({id: 123}, callback); @@ -433,21 +574,21 @@ describe("resource", function() { $httpBackend.flush(); expect(cc).toEqualData({id: 123, number: '9876'}); - expect(callback.mostRecentCall.args[0]).toEqual(cc); - expect(callback.mostRecentCall.args[1]()).toEqual({}); + expect(callback.calls.mostRecent().args[0]).toEqual(cc); + expect(callback.calls.mostRecent().args[1]()).toEqual(Object.create(null)); }); it('should send correct headers', function() { $httpBackend.expectPUT('/CreditCard/123', undefined, function(headers) { - return headers['If-None-Match'] == "*"; + return headers['If-None-Match'] === '*'; }).respond({id:123}); CreditCard.conditionalPut({id: {key:123}}); }); - it("should read partial resource", function() { + it('should read partial resource', function() { $httpBackend.expect('GET', '/CreditCard').respond([{id:{key:123}}]); var ccs = CreditCard.query(); @@ -461,13 +602,13 @@ describe("resource", function() { $httpBackend.expect('GET', '/CreditCard/123').respond({id: {key: 123}, number: '9876'}); cc.$get(callback); $httpBackend.flush(); - expect(callback.mostRecentCall.args[0]).toEqual(cc); - expect(callback.mostRecentCall.args[1]()).toEqual({}); + expect(callback.calls.mostRecent().args[0]).toEqual(cc); + expect(callback.calls.mostRecent().args[1]()).toEqual(Object.create(null)); expect(cc.number).toEqual('9876'); }); - it("should update resource", function() { + it('should update resource', function() { $httpBackend.expect('POST', '/CreditCard/123', '{"id":{"key":123},"name":"misko"}'). respond({id: {key: 123}, name: 'rama'}); @@ -478,7 +619,7 @@ describe("resource", function() { }); - it("should query resource", function() { + it('should query resource', function() { $httpBackend.expect('GET', '/CreditCard?key=value').respond([{id: 1}, {id: 2}]); var ccs = CreditCard.query({key: 'value'}, callback); @@ -487,12 +628,12 @@ describe("resource", function() { $httpBackend.flush(); expect(ccs).toEqualData([{id:1}, {id:2}]); - expect(callback.mostRecentCall.args[0]).toEqual(ccs); - expect(callback.mostRecentCall.args[1]()).toEqual({}); + expect(callback.calls.mostRecent().args[0]).toEqual(ccs); + expect(callback.calls.mostRecent().args[1]()).toEqual(Object.create(null)); }); - it("should have all arguments optional", function() { + it('should have all arguments optional', function() { $httpBackend.expect('GET', '/CreditCard').respond([{id:1}]); var log = ''; @@ -510,17 +651,17 @@ describe("resource", function() { expect(callback).not.toHaveBeenCalled(); $httpBackend.flush(); - expect(callback.mostRecentCall.args[0]).toEqualData({}); - expect(callback.mostRecentCall.args[1]()).toEqual({}); + expect(callback.calls.mostRecent().args[0]).toEqualData({}); + expect(callback.calls.mostRecent().args[1]()).toEqual(Object.create(null)); - callback.reset(); + callback.calls.reset(); $httpBackend.expect('DELETE', '/CreditCard/333').respond(204, null); CreditCard.remove({id:333}, callback); expect(callback).not.toHaveBeenCalled(); $httpBackend.flush(); - expect(callback.mostRecentCall.args[0]).toEqualData({}); - expect(callback.mostRecentCall.args[1]()).toEqual({}); + expect(callback.calls.mostRecent().args[0]).toEqualData({}); + expect(callback.calls.mostRecent().args[1]()).toEqual(Object.create(null)); }); @@ -539,7 +680,7 @@ describe("resource", function() { }); - it("should patch a resource", function() { + it('should patch a resource', function() { $httpBackend.expectPATCH('/CreditCard/123', '{"name":"igor"}'). respond({id: 123, name: 'rama'}); @@ -568,8 +709,8 @@ describe("resource", function() { $httpBackend.flush(); expect(cc).toEqualData({id:123}); - expect(callback.mostRecentCall.args[0]).toEqual(cc); - expect(callback.mostRecentCall.args[1]()).toEqual({header1: 'a'}); + expect(callback.calls.mostRecent().args[0]).toEqual(cc); + expect(callback.calls.mostRecent().args[1]()).toEqual(extend(Object.create(null), {header1: 'a'})); }); @@ -590,20 +731,10 @@ describe("resource", function() { }); - it('should bind default parameters', function() { - $httpBackend.expect('GET', '/CreditCard/123.visa?minimum=0.05').respond({id: 123}); - var Visa = CreditCard.bind({verb:'.visa', minimum:0.05}); - var visa = Visa.get({id:123}); - $httpBackend.flush(); - expect(visa).toEqualData({id:123}); - }); - - it('should support dynamic default parameters (global)', function() { var currentGroup = 'students', Person = $resource('/Person/:group/:id', { group: function() { return currentGroup; }}); - $httpBackend.expect('GET', '/Person/students/fedor').respond({id: 'fedor', email: 'f@f.com'}); var fedor = Person.get({id: 'fedor'}); @@ -613,6 +744,32 @@ describe("resource", function() { }); + it('should pass resource object to dynamic default parameters', function() { + var Person = $resource('/Person/:id', { + id: function(data) { + return data ? data.id : 'fedor'; + } + }); + + $httpBackend.expect('GET', '/Person/fedor').respond( + {id: 'fedor', email: 'f@f.com', count: 1}); + + var fedor = Person.get(); + $httpBackend.flush(); + + expect(fedor).toEqualData({id: 'fedor', email: 'f@f.com', count: 1}); + + $httpBackend.expect('POST', '/Person/fedor2').respond( + {id: 'fedor2', email: 'f2@f.com', count: 2}); + + fedor.id = 'fedor2'; + fedor.$save(); + $httpBackend.flush(); + + expect(fedor).toEqualData({id: 'fedor2', email: 'f2@f.com', count: 2}); + }); + + it('should support dynamic default parameters (action specific)', function() { var currentGroup = 'students', Person = $resource('/Person/:group/:id', {}, { @@ -669,6 +826,24 @@ describe("resource", function() { expect(json).toEqual({id: 123, number: '9876', $myProp: 'still here'}); }); + it('should not include $cancelRequest when resource is toJson\'ed', function() { + $httpBackend.whenGET('/CreditCard').respond({}); + + var CreditCard = $resource('/CreditCard', {}, { + get: { + method: 'GET', + cancellable: true + } + }); + + var card = CreditCard.get(); + var json = card.toJSON(); + + expect(card.$cancelRequest).toBeDefined(); + expect(json.$cancelRequest).toBeUndefined(); + }); + + describe('promise api', function() { var $rootScope; @@ -691,7 +866,7 @@ describe("resource", function() { $httpBackend.flush(); expect(callback).toHaveBeenCalledOnce(); - expect(callback.mostRecentCall.args[0]).toBe(cc); + expect(callback.calls.mostRecent().args[0]).toBe(cc); }); @@ -702,7 +877,7 @@ describe("resource", function() { cc.$promise.then(callback); $httpBackend.flush(); - callback.reset(); + callback.calls.reset(); cc.$promise.then(callback); $rootScope.$apply(); //flush async queue @@ -743,7 +918,7 @@ describe("resource", function() { cc.$promise.then(null, callback); $httpBackend.flush(); - var response = callback.mostRecentCall.args[0]; + var response = callback.calls.mostRecent().args[0]; expect(response.data).toEqual('resource not found'); expect(response.status).toEqual(404); @@ -801,7 +976,7 @@ describe("resource", function() { $httpBackend.flush(); expect(callback).toHaveBeenCalledOnce(); expect(cc).toEqualData({id: 123, number: '9876'}); - callback.reset(); + callback.calls.reset(); $httpBackend.expect('POST', '/CreditCard').respond({id: 1, number: '9'}); @@ -838,6 +1013,7 @@ describe("resource", function() { expect(cc.url).toBe('/new-id'); }); + it('should pass the same transformed value to success callbacks and to promises', function() { $httpBackend.expect('GET', '/CreditCard').respond(200, { value: 'original' }); @@ -884,7 +1060,7 @@ describe("resource", function() { $httpBackend.flush(); expect(callback).toHaveBeenCalledOnce(); - expect(callback.mostRecentCall.args[0]).toBe(ccs); + expect(callback.calls.mostRecent().args[0]).toBe(ccs); }); @@ -895,7 +1071,7 @@ describe("resource", function() { ccs.$promise.then(callback); $httpBackend.flush(); - callback.reset(); + callback.calls.reset(); ccs.$promise.then(callback); $rootScope.$apply(); //flush async queue @@ -922,7 +1098,7 @@ describe("resource", function() { ccs.$promise.then(null, callback); $httpBackend.flush(); - var response = callback.mostRecentCall.args[0]; + var response = callback.calls.mostRecent().args[0]; expect(response.data).toEqual('resource not found'); expect(response.status).toEqual(404); @@ -955,71 +1131,477 @@ describe("resource", function() { }); }); - it('should allow per action response interceptor that gets full response', function() { - CreditCard = $resource('/CreditCard', {}, { - query: { - method: 'get', - isArray: true, - interceptor: { - response: function(response) { - return response; + + describe('requestInterceptor', function() { + var rejectReason = {'lol':'cat'}; + var successSpy, failureSpy; + + beforeEach(function() { + successSpy = jasmine.createSpy('successSpy'); + failureSpy = jasmine.createSpy('failureSpy'); + }); + + it('should allow per action request interceptor that gets full configuration', function() { + var CreditCard = $resource('/CreditCard', {}, { + query: { + method: 'get', + isArray: true, + interceptor: { + request: function(httpConfig) { + callback(httpConfig); + return httpConfig; + } } } - } + }); + + $httpBackend.expect('GET', '/CreditCard').respond([{id: 1}]); + + var resource = CreditCard.query(); + resource.$promise.then(successSpy, failureSpy); + + $httpBackend.flush(); + expect(callback).toHaveBeenCalledOnce(); + expect(successSpy).toHaveBeenCalledOnce(); + expect(failureSpy).not.toHaveBeenCalled(); + + expect(callback).toHaveBeenCalledWith({ + 'method': 'get', + 'url': '/CreditCard' + }); }); - $httpBackend.expect('GET', '/CreditCard').respond([{id: 1}]); + it('should call $http with the value returned from requestInterceptor', function() { + var CreditCard = $resource('/CreditCard', {}, { + query: { + method: 'get', + isArray: true, + interceptor: { + request: function(httpConfig) { + httpConfig.url = '/DebitCard'; + return httpConfig; + } + } + } + }); - var ccs = CreditCard.query(); + $httpBackend.expect('GET', '/DebitCard').respond([{id: 1}]); - ccs.$promise.then(callback); + var resource = CreditCard.query(); + resource.$promise.then(successSpy, failureSpy); - $httpBackend.flush(); - expect(callback).toHaveBeenCalledOnce(); + $httpBackend.flush(); + expect(successSpy).toHaveBeenCalledOnceWith(jasmine.arrayContaining([ + jasmine.objectContaining({id: 1}) + ])); + expect(failureSpy).not.toHaveBeenCalled(); + }); - var response = callback.mostRecentCall.args[0]; - expect(response.resource).toBe(ccs); - expect(response.status).toBe(200); - expect(response.config).toBeDefined(); - }); + it('should abort the operation if the requestInterceptor rejects the operation', function() { + var CreditCard = $resource('/CreditCard', {}, { + query: { + method: 'get', + isArray: true, + interceptor: { + request: function() { + return $q.reject(rejectReason); + } + } + } + }); + var resource = CreditCard.query(); + resource.$promise.then(successSpy, failureSpy); - it('should allow per action responseError interceptor that gets full response', function() { - CreditCard = $resource('/CreditCard', {}, { - query: { - method: 'get', - isArray: true, - interceptor: { - responseError: function(response) { - return response; + // Make sure all promises resolve. + $rootScope.$apply(); + + // Ensure the resource promise was rejected + expect(resource.$resolved).toBeTruthy(); + expect(successSpy).not.toHaveBeenCalled(); + expect(failureSpy).toHaveBeenCalledOnceWith(rejectReason); + + // Ensure that no requests were made. + $httpBackend.verifyNoOutstandingRequest(); + }); + + it('should call requestErrorInterceptor if requestInterceptor rejects the operation', function() { + var CreditCard = $resource('/CreditCard', {}, { + query: { + method: 'get', + isArray: true, + interceptor: { + request: function() { + return $q.reject(rejectReason); + }, + requestError: function(rejection) { + callback(rejection); + return $q.reject(rejection); + } } } + }); + + var resource = CreditCard.query(); + resource.$promise.then(successSpy, failureSpy); + $rootScope.$digest(); + + expect(callback).toHaveBeenCalledOnceWith(rejectReason); + expect(successSpy).not.toHaveBeenCalled(); + expect(failureSpy).toHaveBeenCalledOnceWith(rejectReason); + + // Ensure that no requests were made. + $httpBackend.verifyNoOutstandingRequest(); + }); + + it('should abort the operation if a requestErrorInterceptor rejects the operation', function() { + var CreditCard = $resource('/CreditCard', {}, { + query: { + method: 'get', + isArray: true, + interceptor: { + request: function() { + return $q.reject(rejectReason); + }, + requestError: function(rejection) { + return $q.reject(rejection); + } + } + } + }); + + var resource = CreditCard.query(); + resource.$promise.then(successSpy, failureSpy); + $rootScope.$apply(); + + expect(resource.$resolved).toBeTruthy(); + expect(successSpy).not.toHaveBeenCalled(); + expect(failureSpy).toHaveBeenCalledOnceWith(rejectReason); + + // Ensure that no requests were made. + $httpBackend.verifyNoOutstandingRequest(); + }); + + it('should continue the operation if a requestErrorInterceptor rescues it', function() { + var CreditCard = $resource('/CreditCard', {}, { + query: { + method: 'get', + isArray: true, + interceptor: { + request: function(httpConfig) { + return $q.reject(httpConfig); + }, + requestError: function(httpConfig) { + return $q.resolve(httpConfig); + } + } + } + }); + + $httpBackend.expect('GET', '/CreditCard').respond([{id: 1}]); + + var resource = CreditCard.query(); + resource.$promise.then(successSpy, failureSpy); + $httpBackend.flush(); + + expect(resource.$resolved).toBeTruthy(); + expect(successSpy).toHaveBeenCalledOnceWith(jasmine.arrayContaining([ + jasmine.objectContaining({id: 1}) + ])); + expect(failureSpy).not.toHaveBeenCalled(); + + $httpBackend.verifyNoOutstandingRequest(); + }); + }); + + + describe('responseInterceptor', function() { + it('should allow per action response interceptor that gets full response', function() { + var response; + + $httpBackend.expect('GET', '/CreditCard').respond(201, {id: 1}, {foo: 'bar'}, 'Ack'); + CreditCard = $resource('/CreditCard', {}, { + get: { + method: 'get', + interceptor: {response: function(resp) { response = resp; }} + } + }); + + var cc = CreditCard.get(); + $httpBackend.flush(); + + expect(response.resource).toBe(cc); + expect(response.config).toBeDefined(); + expect(response.status).toBe(201); + expect(response.statusText).toBe('Ack'); + expect(response.headers()).toEqual({foo: 'bar'}); + }); + + + it('should allow per action responseError interceptor that gets full response', function() { + var response; + + $httpBackend.expect('GET', '/CreditCard').respond(404, {ignored: 'stuff'}, {foo: 'bar'}, 'Ack'); + CreditCard = $resource('/CreditCard', {}, { + get: { + method: 'get', + interceptor: {responseError: function(resp) { response = resp; }} + } + }); + + var cc = CreditCard.get(); + $httpBackend.flush(); + + expect(response.resource).toBe(cc); + expect(response.config).toBeDefined(); + expect(response.status).toBe(404); + expect(response.statusText).toBe('Ack'); + expect(response.headers()).toEqual({foo: 'bar'}); + }); + + + it('should fulfill the promise with the value returned by the response interceptor', + function() { + $httpBackend.whenGET('/CreditCard').respond(200); + CreditCard = $resource('/CreditCard', {}, { + test1: { + method: 'get', + interceptor: {response: function() { return 'foo'; }} + }, + test2: { + method: 'get', + interceptor: {response: function() { return $q.resolve('bar'); }} + }, + test3: { + method: 'get', + interceptor: {response: function() { return $q.reject('baz'); }} + } + }); + + CreditCard.test1().$promise.then(callback); + $httpBackend.flush(); + expect(callback).toHaveBeenCalledOnceWith('foo'); + + callback.calls.reset(); + + CreditCard.test2().$promise.then(callback); + $httpBackend.flush(); + expect(callback).toHaveBeenCalledOnceWith('bar'); + + callback.calls.reset(); + + CreditCard.test3().$promise.then(null, callback); + $httpBackend.flush(); + expect(callback).toHaveBeenCalledOnceWith('baz'); + } + ); + + + it('should fulfill the promise with the value returned by the responseError interceptor', + function() { + $httpBackend.whenGET('/CreditCard').respond(404); + CreditCard = $resource('/CreditCard', {}, { + test1: { + method: 'get', + interceptor: {responseError: function() { return 'foo'; }} + }, + test2: { + method: 'get', + interceptor: {responseError: function() { return $q.resolve('bar'); }} + }, + test3: { + method: 'get', + interceptor: {responseError: function() { return $q.reject('baz'); }} + } + }); + + CreditCard.test1().$promise.then(callback); + $httpBackend.flush(); + expect(callback).toHaveBeenCalledOnceWith('foo'); + + callback.calls.reset(); + + CreditCard.test2().$promise.then(callback); + $httpBackend.flush(); + expect(callback).toHaveBeenCalledOnceWith('bar'); + + callback.calls.reset(); + + CreditCard.test3().$promise.then(null, callback); + $httpBackend.flush(); + expect(callback).toHaveBeenCalledOnceWith('baz'); } + ); + + + it('should call the success callback when response interceptor succeeds', function() { + $httpBackend.whenGET('/CreditCard').respond(200); + CreditCard = $resource('/CreditCard', {}, { + test1: { + method: 'get', + interceptor: {response: function() { return 'foo'; }} + }, + test2: { + method: 'get', + interceptor: {response: function() { return $q.resolve('bar'); }} + } + }); + + CreditCard.test1(callback); + $httpBackend.flush(); + expect(callback).toHaveBeenCalledOnceWith('foo', jasmine.any(Function), 200, ''); + + callback.calls.reset(); + + CreditCard.test2(callback); + $httpBackend.flush(); + expect(callback).toHaveBeenCalledOnceWith('bar', jasmine.any(Function), 200, ''); }); - $httpBackend.expect('GET', '/CreditCard').respond(404); - var ccs = CreditCard.query(); + it('should call the error callback when response interceptor fails', function() { + $httpBackend.whenGET('/CreditCard').respond(200); + CreditCard = $resource('/CreditCard', {}, { + test1: { + method: 'get', + interceptor: {response: function() { throw 'foo'; }} + }, + test2: { + method: 'get', + interceptor: {response: function() { return $q.reject('bar'); }} + } + }); + + CreditCard.test1(noop, callback); + $httpBackend.flush(); + expect(callback).toHaveBeenCalledOnceWith('foo'); + + callback.calls.reset(); - ccs.$promise.then(callback); + CreditCard.test2(noop, callback); + $httpBackend.flush(); + expect(callback).toHaveBeenCalledOnceWith('bar'); + }); - $httpBackend.flush(); - expect(callback).toHaveBeenCalledOnce(); - var response = callback.mostRecentCall.args[0]; - expect(response.status).toBe(404); - expect(response.config).toBeDefined(); + it('should call the success callback when responseError interceptor succeeds', function() { + $httpBackend.whenGET('/CreditCard').respond(404); + CreditCard = $resource('/CreditCard', {}, { + test1: { + method: 'get', + interceptor: {responseError: function() { return 'foo'; }} + }, + test2: { + method: 'get', + interceptor: {responseError: function() { return $q.resolve('bar'); }} + } + }); + + CreditCard.test1(callback); + $httpBackend.flush(); + expect(callback).toHaveBeenCalledOnceWith('foo', jasmine.any(Function), 404, ''); + + callback.calls.reset(); + + CreditCard.test2(callback); + $httpBackend.flush(); + expect(callback).toHaveBeenCalledOnceWith('bar', jasmine.any(Function), 404, ''); + }); + + + it('should call the error callback when responseError interceptor fails', function() { + $httpBackend.whenGET('/CreditCard').respond(404); + CreditCard = $resource('/CreditCard', {}, { + test1: { + method: 'get', + interceptor: {responseError: function() { throw 'foo'; }} + }, + test2: { + method: 'get', + interceptor: {responseError: function() { return $q.reject('bar'); }} + } + }); + + CreditCard.test1(noop, callback); + $httpBackend.flush(); + expect(callback).toHaveBeenCalledOnceWith('foo'); + + callback.calls.reset(); + + CreditCard.test2(noop, callback); + $httpBackend.flush(); + expect(callback).toHaveBeenCalledOnceWith('bar'); + }); }); }); + describe('success mode', function() { + it('should call the success callback (as 1st argument) on 2xx responses', function() { + var instance, headers, status, statusText; + var successCb = jasmine.createSpy('successCb').and.callFake(function(d, h, s, t) { + expect(d).toBe(instance); + expect(h()).toEqual(jasmine.objectContaining(headers)); + expect(s).toBe(status); + expect(t).toBe(statusText); + }); + + instance = CreditCard.get(successCb); + headers = {foo: 'bar'}; + status = 200; + statusText = 'OK'; + $httpBackend.expect('GET', '/CreditCard').respond(status, {}, headers, statusText); + $httpBackend.flush(); + + expect(successCb).toHaveBeenCalledOnce(); + + instance = CreditCard.get(successCb); + headers = {baz: 'qux'}; + status = 299; + statusText = 'KO'; + $httpBackend.expect('GET', '/CreditCard').respond(status, {}, headers, statusText); + $httpBackend.flush(); + + expect(successCb).toHaveBeenCalledTimes(2); + }); + + + it('should call the success callback (as 2nd argument) on 2xx responses', function() { + var instance, headers, status, statusText; + var successCb = jasmine.createSpy('successCb').and.callFake(function(d, h, s, t) { + expect(d).toBe(instance); + expect(h()).toEqual(jasmine.objectContaining(headers)); + expect(s).toBe(status); + expect(t).toBe(statusText); + }); + + instance = CreditCard.get({id: 123}, successCb); + headers = {foo: 'bar'}; + status = 200; + statusText = 'OK'; + $httpBackend.expect('GET', '/CreditCard/123').respond(status, {}, headers, statusText); + $httpBackend.flush(); + + expect(successCb).toHaveBeenCalledOnce(); + + instance = CreditCard.get({id: 456}, successCb); + headers = {baz: 'qux'}; + status = 299; + statusText = 'KO'; + $httpBackend.expect('GET', '/CreditCard/456').respond(status, {}, headers, statusText); + $httpBackend.flush(); + + expect(successCb).toHaveBeenCalledTimes(2); + }); + }); + describe('failure mode', function() { var ERROR_CODE = 500, ERROR_RESPONSE = 'Server Error', errorCB; beforeEach(function() { - errorCB = jasmine.createSpy('error').andCallFake(function(response) { + errorCB = jasmine.createSpy('error').and.callFake(function(response) { expect(response.data).toBe(ERROR_RESPONSE); expect(response.status).toBe(ERROR_CODE); }); @@ -1102,7 +1684,7 @@ describe("resource", function() { expect(user).toEqualData([{id: 1, name: 'user1'}]); }); - it('should work with the action is overriden', function() { + it('should work with the action is overridden', function() { $httpBackend.expect('GET', '/users.json').respond([{id: 1, name: 'user1'}]); var UserService = $resource('/users/:user_id', {user_id: '@id'}, { query: { @@ -1117,10 +1699,10 @@ describe("resource", function() { }); it('should not convert string literals in array into Resource objects', function() { - $httpBackend.expect('GET', '/names.json').respond(["mary", "jane"]); + $httpBackend.expect('GET', '/names.json').respond(['mary', 'jane']); var strings = $resource('/names.json').query(); $httpBackend.flush(); - expect(strings).toEqualData(["mary", "jane"]); + expect(strings).toEqualData(['mary', 'jane']); }); it('should not convert number literals in array into Resource objects', function() { @@ -1163,7 +1745,7 @@ describe("resource", function() { expect(user).toEqualData({id: 1, name: 'user1'}); }); - it('should work with the action is overriden', function() { + it('should work with the action is overridden', function() { $httpBackend.expect('GET', '/users/1.json').respond({id: 1, name: 'user1'}); var UserService = $resource('/users/:user_id', {user_id: '@id'}, { get: { @@ -1177,7 +1759,7 @@ describe("resource", function() { }); }); - describe("save", function() { + describe('save', function() { it('should append the suffix', function() { $httpBackend.expect('POST', '/users.json', '{"name":"user1"}').respond({id: 123, name: 'user1'}); var UserService = $resource('/users/:user_id.json', {user_id: '@id'}); @@ -1187,8 +1769,8 @@ describe("resource", function() { $httpBackend.flush(); expect(user).toEqualData({id: 123, name: 'user1'}); expect(callback).toHaveBeenCalledOnce(); - expect(callback.mostRecentCall.args[0]).toEqual(user); - expect(callback.mostRecentCall.args[1]()).toEqual({}); + expect(callback.calls.mostRecent().args[0]).toEqual(user); + expect(callback.calls.mostRecent().args[1]()).toEqual(Object.create(null)); }); it('should append when an id is supplied', function() { @@ -1199,8 +1781,8 @@ describe("resource", function() { $httpBackend.flush(); expect(user).toEqualData({id: 123, name: 'newName'}); expect(callback).toHaveBeenCalledOnce(); - expect(callback.mostRecentCall.args[0]).toEqual(user); - expect(callback.mostRecentCall.args[1]()).toEqual({}); + expect(callback.calls.mostRecent().args[0]).toEqual(user); + expect(callback.calls.mostRecent().args[1]()).toEqual(Object.create(null)); }); it('should append when an id is supplied and the format is a parameter', function() { @@ -1211,8 +1793,8 @@ describe("resource", function() { $httpBackend.flush(); expect(user).toEqualData({id: 123, name: 'newName'}); expect(callback).toHaveBeenCalledOnce(); - expect(callback.mostRecentCall.args[0]).toEqual(user); - expect(callback.mostRecentCall.args[1]()).toEqual({}); + expect(callback.calls.mostRecent().args[0]).toEqual(user); + expect(callback.calls.mostRecent().args[1]()).toEqual(Object.create(null)); }); }); @@ -1229,6 +1811,18 @@ describe("resource", function() { $httpBackend.expect('POST', '/users/.json').respond(); $resource('/users/\\.json').save({}); }); + it('should work with save() if dynamic params', function() { + $httpBackend.expect('POST', '/users/.json').respond(); + $resource('/users/:json', {json: '\\.json'}).save({}); + }); + it('should work with query() if dynamic params', function() { + $httpBackend.expect('GET', '/users/.json').respond(); + $resource('/users/:json', {json: '\\.json'}).query(); + }); + it('should work with get() if dynamic params', function() { + $httpBackend.expect('GET', '/users/.json').respond(); + $resource('/users/:json', {json: '\\.json'}).get(); + }); }); }); @@ -1294,7 +1888,53 @@ describe("resource", function() { }); }); -describe('resource', function() { +describe('extra params', function() { + var $http; + var $httpBackend; + var $resource; + var $rootScope; + + beforeEach(module('ngResource')); + + beforeEach(module(function($provide) { + $provide.decorator('$http', function($delegate) { + return jasmine.createSpy('$http').and.callFake($delegate); + }); + })); + + beforeEach(inject(function(_$http_, _$httpBackend_, _$resource_, _$rootScope_) { + $http = _$http_; + $httpBackend = _$httpBackend_; + $resource = _$resource_; + $rootScope = _$rootScope_; + })); + + afterEach(function() { + $httpBackend.verifyNoOutstandingExpectation(); + }); + + + it('should pass extra params to `$http` as `config.params`', function() { + $httpBackend.expectGET('/bar?baz=qux').respond('{}'); + + var R = $resource('/:foo'); + R.get({foo: 'bar', baz: 'qux'}); + + $rootScope.$digest(); + expect($http).toHaveBeenCalledWith(jasmine.objectContaining({params: {baz: 'qux'}})); + }); + + it('should pass extra params even if `Object.prototype` has properties with the same name', + function() { + $httpBackend.expectGET('/foo?toString=bar').respond('{}'); + + var R = $resource('/foo'); + R.get({toString: 'bar'}); + } + ); +}); + +describe('errors', function() { var $httpBackend, $resource; beforeEach(module(function($exceptionHandlerProvider) { @@ -1321,9 +1961,9 @@ describe('resource', function() { expect(successSpy).not.toHaveBeenCalled(); expect(failureSpy).toHaveBeenCalled(); - expect(failureSpy.mostRecentCall.args[0]).toMatch( - /^\[\$resource:badcfg\] Error in resource configuration for action `query`\. Expected response to contain an array but got an object/ - ); + expect(failureSpy.calls.mostRecent().args[0]).toEqualMinErr('$resource', 'badcfg', + 'Error in resource configuration for action `query`. ' + + 'Expected response to contain an array but got an object (Request: GET /Customer/123)'); }); it('should fail if action expects an array but response is an object', function() { @@ -1338,10 +1978,573 @@ describe('resource', function() { expect(successSpy).not.toHaveBeenCalled(); expect(failureSpy).toHaveBeenCalled(); - expect(failureSpy.mostRecentCall.args[0]).toMatch( - /^\[\$resource:badcfg\] Error in resource configuration for action `get`\. Expected response to contain an object but got an array/ - ); + expect(failureSpy.calls.mostRecent().args[0]).toEqualMinErr('$resource', 'badcfg', + 'Error in resource configuration for action `get`. ' + + 'Expected response to contain an object but got an array (Request: GET /Customer/123)'); + }); +}); + +describe('handling rejections', function() { + var $exceptionHandler; + var $httpBackend; + var $resource; + + beforeEach(module('ngResource')); + beforeEach(module(function($exceptionHandlerProvider) { + $exceptionHandlerProvider.mode('log'); + })); + + beforeEach(inject(function(_$exceptionHandler_, _$httpBackend_, _$resource_) { + $exceptionHandler = _$exceptionHandler_; + $httpBackend = _$httpBackend_; + $resource = _$resource_; + + $httpBackend.whenGET('/CreditCard').respond(404); + })); + + + it('should reject the promise even when there is an error callback', function() { + var errorCb1 = jasmine.createSpy('errorCb1'); + var errorCb2 = jasmine.createSpy('errorCb2'); + var CreditCard = $resource('/CreditCard'); + + CreditCard.get(noop, errorCb1).$promise.catch(errorCb2); + $httpBackend.flush(); + + expect(errorCb1).toHaveBeenCalledOnce(); + expect(errorCb2).toHaveBeenCalledOnce(); }); + it('should report a PUR when no error callback or responseError interceptor is provided', + function() { + var CreditCard = $resource('/CreditCard'); + + CreditCard.get(); + $httpBackend.flush(); + + expect($exceptionHandler.errors.length).toBe(1); + expect($exceptionHandler.errors[0]).toMatch(/^Possibly unhandled rejection/); + } + ); + + + it('should not report a PUR when an error callback or responseError interceptor is provided', + function() { + var CreditCard = $resource('/CreditCard', {}, { + test1: { + method: 'GET' + }, + test2: { + method: 'GET', + interceptor: {responseError: function() { return {}; }} + } + }); + + // With error callback + CreditCard.test1(noop, noop); + $httpBackend.flush(); + + expect($exceptionHandler.errors.length).toBe(0); + + // With responseError interceptor + CreditCard.test2(); + $httpBackend.flush(); + + expect($exceptionHandler.errors.length).toBe(0); + + // With error callback and responseError interceptor + CreditCard.test2(noop, noop); + $httpBackend.flush(); + + expect($exceptionHandler.errors.length).toBe(0); + } + ); + + + it('should report a PUR when the responseError interceptor returns a rejected promise', + inject(function($q) { + var CreditCard = $resource('/CreditCard', {}, { + test: { + method: 'GET', + interceptor: {responseError: function() { return $q.reject({}); }} + } + }); + + CreditCard.test(); + $httpBackend.flush(); + + expect($exceptionHandler.errors.length).toBe(1); + expect($exceptionHandler.errors[0]).toMatch(/^Possibly unhandled rejection/); + }) + ); + + + it('should not swallow exceptions in success callback when error callback is provided', + function() { + $httpBackend.expectGET('/CreditCard/123').respond(null); + var CreditCard = $resource('/CreditCard/:id'); + CreditCard.get({id: 123}, + function(res) { throw new Error('should be caught'); }, + function() {}); + + $httpBackend.flush(); + expect($exceptionHandler.errors.length).toBe(1); + expect($exceptionHandler.errors[0]).toMatch(/^Error: should be caught/); + } + ); + + + it('should not swallow exceptions in success callback when error callback is not provided', + function() { + $httpBackend.expectGET('/CreditCard/123').respond(null); + var CreditCard = $resource('/CreditCard/:id'); + CreditCard.get({id: 123}, + function(res) { throw new Error('should be caught'); }); + + $httpBackend.flush(); + expect($exceptionHandler.errors.length).toBe(1); + expect($exceptionHandler.errors[0]).toMatch(/^Error: should be caught/); + } + ); + + + it('should not swallow exceptions in success callback when error callback is provided and has responseError interceptor', + function() { + $httpBackend.expectGET('/CreditCard/123').respond(null); + var CreditCard = $resource('/CreditCard/:id', null, { + get: { + method: 'GET', + interceptor: {responseError: function() {}} + } + }); + + CreditCard.get({id: 123}, + function(res) { throw new Error('should be caught'); }, + function() {}); + + $httpBackend.flush(); + expect($exceptionHandler.errors.length).toBe(1); + expect($exceptionHandler.errors[0]).toMatch(/^Error: should be caught/); + } + ); + + + it('should not swallow exceptions in success callback when error callback is not provided and has responseError interceptor', + function() { + $httpBackend.expectGET('/CreditCard/123').respond(null); + var CreditCard = $resource('/CreditCard/:id', null, { + get: { + method: 'GET', + interceptor: {responseError: function() {}} + } + }); + + CreditCard.get({id: 123}, + function(res) { throw new Error('should be caught'); }); + + $httpBackend.flush(); + expect($exceptionHandler.errors.length).toBe(1); + expect($exceptionHandler.errors[0]).toMatch(/^Error: should be caught/); + } + ); + + + it('should not propagate exceptions in success callback to the returned promise', function() { + var successCallbackSpy = jasmine.createSpy('successCallback').and.throwError('error'); + var promiseResolveSpy = jasmine.createSpy('promiseResolve'); + var promiseRejectSpy = jasmine.createSpy('promiseReject'); + + $httpBackend.expectGET('/CreditCard/123').respond(null); + var CreditCard = $resource('/CreditCard/:id'); + CreditCard.get({id: 123}, successCallbackSpy). + $promise.then(promiseResolveSpy, promiseRejectSpy); + + $httpBackend.flush(); + expect(successCallbackSpy).toHaveBeenCalled(); + expect(promiseResolveSpy).toHaveBeenCalledWith(jasmine.any(CreditCard)); + expect(promiseRejectSpy).not.toHaveBeenCalled(); + }); + + + it('should not be able to recover from inside the error callback', function() { + var errorCallbackSpy = jasmine.createSpy('errorCallback').and.returnValue({id: 123}); + var promiseResolveSpy = jasmine.createSpy('promiseResolve'); + var promiseRejectSpy = jasmine.createSpy('promiseReject'); + + $httpBackend.expectGET('/CreditCard/123').respond(404); + var CreditCard = $resource('/CreditCard/:id'); + CreditCard.get({id: 123}, noop, errorCallbackSpy). + $promise.then(promiseResolveSpy, promiseRejectSpy); + + $httpBackend.flush(); + expect(errorCallbackSpy).toHaveBeenCalled(); + expect(promiseResolveSpy).not.toHaveBeenCalled(); + expect(promiseRejectSpy).toHaveBeenCalledWith(jasmine.objectContaining({status: 404})); + }); + + + describe('requestInterceptor', function() { + var rejectReason = {'lol':'cat'}; + var $q, $rootScope; + var successSpy, failureSpy, callback; + + beforeEach(inject(function(_$q_, _$rootScope_) { + $q = _$q_; + $rootScope = _$rootScope_; + + successSpy = jasmine.createSpy('successSpy'); + failureSpy = jasmine.createSpy('failureSpy'); + callback = jasmine.createSpy(); + })); + + it('should call requestErrorInterceptor if requestInterceptor throws an error', function() { + var CreditCard = $resource('/CreditCard', {}, { + query: { + method: 'get', + isArray: true, + interceptor: { + request: function() { + throw rejectReason; + }, + requestError: function(rejection) { + callback(rejection); + return $q.reject(rejection); + } + } + } + }); + + var resource = CreditCard.query(); + resource.$promise.then(successSpy, failureSpy); + $rootScope.$apply(); + + expect(callback).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledWith(rejectReason); + expect(successSpy).not.toHaveBeenCalled(); + expect(failureSpy).toHaveBeenCalledOnce(); + expect(failureSpy).toHaveBeenCalledWith(rejectReason); + + // Ensure that no requests were made. + $httpBackend.verifyNoOutstandingRequest(); + }); + + it('should abort the operation if a requestErrorInterceptor throws an exception', function() { + var CreditCard = $resource('/CreditCard', {}, { + query: { + method: 'get', + isArray: true, + interceptor: { + request: function() { + return $q.reject(); + }, + requestError: function() { + throw rejectReason; + } + } + } + }); + + var resource = CreditCard.query(); + resource.$promise.then(successSpy, failureSpy); + $rootScope.$apply(); + + expect(resource.$resolved).toBeTruthy(); + expect(successSpy).not.toHaveBeenCalled(); + expect(failureSpy).toHaveBeenCalledOnce(); + expect(failureSpy).toHaveBeenCalledWith(rejectReason); + + // Ensure that no requests were made. + $httpBackend.verifyNoOutstandingRequest(); + }); + }); +}); + +describe('cancelling requests', function() { + var httpSpy; + var $httpBackend; + var $resource; + var $timeout; + + beforeEach(module('ngResource', function($provide) { + $provide.decorator('$http', function($delegate) { + httpSpy = jasmine.createSpy('$http').and.callFake($delegate); + return httpSpy; + }); + })); + + beforeEach(inject(function(_$httpBackend_, _$resource_, _$timeout_) { + $httpBackend = _$httpBackend_; + $resource = _$resource_; + $timeout = _$timeout_; + })); + + it('should accept numeric timeouts in actions and pass them to $http', function() { + $httpBackend.whenGET('/CreditCard').respond({}); + + var CreditCard = $resource('/CreditCard', {}, { + get: { + method: 'GET', + timeout: 10000 + } + }); + + CreditCard.get(); + $httpBackend.flush(); + + expect(httpSpy).toHaveBeenCalledOnce(); + expect(httpSpy.calls.argsFor(0)[0].timeout).toBe(10000); + }); + + it('should delete non-numeric timeouts in actions and log a $debug message', + inject(function($log, $q) { + spyOn($log, 'debug'); + $httpBackend.whenGET('/CreditCard').respond({}); + + var CreditCard = $resource('/CreditCard', {}, { + get: { + method: 'GET', + timeout: $q.defer().promise + } + }); + + CreditCard.get(); + $httpBackend.flush(); + + expect(httpSpy).toHaveBeenCalledOnce(); + expect(httpSpy.calls.argsFor(0)[0].timeout).toBeUndefined(); + expect($log.debug).toHaveBeenCalledOnceWith('ngResource:\n' + + ' Only numeric values are allowed as `timeout`.\n' + + ' Promises are not supported in $resource, because the same value would ' + + 'be used for multiple requests. If you are looking for a way to cancel ' + + 'requests, you should use the `cancellable` option.'); + }) + ); + + it('should use `cancellable` value if passed a non-numeric `timeout` in an action', + inject(function($log, $q, $rootScope) { + spyOn($log, 'debug'); + $httpBackend.whenGET('/CreditCard').respond({}); + + var CreditCard = $resource('/CreditCard', {}, { + get: { + method: 'GET', + timeout: $q.defer().promise, + cancellable: true + } + }); + + var creditCard = CreditCard.get(); + $rootScope.$digest(); + expect(creditCard.$cancelRequest).toBeDefined(); + expect(httpSpy.calls.argsFor(0)[0].timeout).toEqual(jasmine.any($q)); + expect(httpSpy.calls.argsFor(0)[0].timeout.then).toBeDefined(); + + expect($log.debug).toHaveBeenCalledOnceWith('ngResource:\n' + + ' Only numeric values are allowed as `timeout`.\n' + + ' Promises are not supported in $resource, because the same value would ' + + 'be used for multiple requests. If you are looking for a way to cancel ' + + 'requests, you should use the `cancellable` option.'); + }) + ); + + it('should not create a `$cancelRequest` method for instance calls', function() { + $httpBackend.whenPOST('/CreditCard').respond({}); + + var CreditCard = $resource('/CreditCard', {}, { + save1: { + method: 'POST', + cancellable: false + }, + save2: { + method: 'POST', + cancellable: true + } + }); + + var creditCard = new CreditCard(); + + var promise1 = creditCard.$save1(); + expect(promise1.$cancelRequest).toBeUndefined(); + expect(creditCard.$cancelRequest).toBeUndefined(); + + var promise2 = creditCard.$save2(); + expect(promise2.$cancelRequest).toBeUndefined(); + expect(creditCard.$cancelRequest).toBeUndefined(); + + $httpBackend.flush(); + expect(promise1.$cancelRequest).toBeUndefined(); + expect(promise2.$cancelRequest).toBeUndefined(); + expect(creditCard.$cancelRequest).toBeUndefined(); + }); + + it('should not create a `$cancelRequest` method for non-cancellable calls', function() { + var CreditCard = $resource('/CreditCard', {}, { + get: { + method: 'GET', + cancellable: false + } + }); + + var creditCard = CreditCard.get(); + + expect(creditCard.$cancelRequest).toBeUndefined(); + }); + + it('should also take into account `options.cancellable`', function() { + var options = {cancellable: true}; + var CreditCard = $resource('/CreditCard', {}, { + get1: {method: 'GET', cancellable: false}, + get2: {method: 'GET', cancellable: true}, + get3: {method: 'GET'} + }, options); + + var creditCard1 = CreditCard.get1(); + var creditCard2 = CreditCard.get2(); + var creditCard3 = CreditCard.get3(); + + expect(creditCard1.$cancelRequest).toBeUndefined(); + expect(creditCard2.$cancelRequest).toBeDefined(); + expect(creditCard3.$cancelRequest).toBeDefined(); + + options = {cancellable: false}; + CreditCard = $resource('/CreditCard', {}, { + get1: {method: 'GET', cancellable: false}, + get2: {method: 'GET', cancellable: true}, + get3: {method: 'GET'} + }, options); + + creditCard1 = CreditCard.get1(); + creditCard2 = CreditCard.get2(); + creditCard3 = CreditCard.get3(); + + expect(creditCard1.$cancelRequest).toBeUndefined(); + expect(creditCard2.$cancelRequest).toBeDefined(); + expect(creditCard3.$cancelRequest).toBeUndefined(); + }); + + it('should accept numeric timeouts in cancellable actions and cancel the request when timeout occurs', function() { + $httpBackend.whenGET('/CreditCard').respond({}); + + var CreditCard = $resource('/CreditCard', {}, { + get: { + method: 'GET', + timeout: 10000, + cancellable: true + } + }); + + var ccs = CreditCard.get(); + ccs.$promise.catch(noop); + $timeout.flush(); + expect($httpBackend.flush).toThrow(new Error('No pending request to flush !')); + + CreditCard.get(); + expect($httpBackend.flush).not.toThrow(); + + }); + + it('should cancel the request (if cancellable), when calling `$cancelRequest`', function() { + $httpBackend.whenGET('/CreditCard').respond({}); + + var CreditCard = $resource('/CreditCard', {}, { + get: { + method: 'GET', + cancellable: true + } + }); + + var ccs = CreditCard.get(); + ccs.$cancelRequest(); + expect($httpBackend.flush).toThrow(new Error('No pending request to flush !')); + + CreditCard.get(); + expect($httpBackend.flush).not.toThrow(); + }); + + it('should cancel the request, when calling `$cancelRequest` in cancellable actions with timeout defined', function() { + $httpBackend.whenGET('/CreditCard').respond({}); + + var CreditCard = $resource('/CreditCard', {}, { + get: { + method: 'GET', + timeout: 10000, + cancellable: true + } + }); + + var ccs = CreditCard.get(); + ccs.$cancelRequest(); + expect($httpBackend.flush).toThrow(new Error('No pending request to flush !')); + + CreditCard.get(); + expect($httpBackend.flush).not.toThrow(); + }); + + it('should reset `$cancelRequest` after the response arrives', function() { + $httpBackend.whenGET('/CreditCard').respond({}); + + var CreditCard = $resource('/CreditCard', {}, { + get: { + method: 'GET', + cancellable: true + } + }); + + var creditCard = CreditCard.get(); + + expect(creditCard.$cancelRequest).not.toBe(noop); + + $httpBackend.flush(); + + expect(creditCard.$cancelRequest).toBe(noop); + }); + + it('should not break when calling old `$cancelRequest` after the response arrives', function() { + $httpBackend.whenGET('/CreditCard').respond({}); + + var CreditCard = $resource('/CreditCard', {}, { + get: { + method: 'GET', + cancellable: true + } + }); + + var creditCard = CreditCard.get(); + var cancelRequest = creditCard.$cancelRequest; + + $httpBackend.flush(); + + expect(cancelRequest).not.toBe(noop); + expect(cancelRequest).not.toThrow(); + }); +}); + +describe('configuring `cancellable` on the provider', function() { + var $resource; + + beforeEach(module('ngResource', function($resourceProvider) { + $resourceProvider.defaults.cancellable = true; + })); + + beforeEach(inject(function(_$resource_) { + $resource = _$resource_; + })); + + it('should also take into account `$resourceProvider.defaults.cancellable`', function() { + var CreditCard = $resource('/CreditCard', {}, { + get1: {method: 'GET', cancellable: false}, + get2: {method: 'GET', cancellable: true}, + get3: {method: 'GET'} + }); + + var creditCard1 = CreditCard.get1(); + var creditCard2 = CreditCard.get2(); + var creditCard3 = CreditCard.get3(); + + expect(creditCard1.$cancelRequest).toBeUndefined(); + expect(creditCard2.$cancelRequest).toBeDefined(); + expect(creditCard3.$cancelRequest).toBeDefined(); + }); +}); }); diff --git a/test/ngRoute/directive/ngViewSpec.js b/test/ngRoute/directive/ngViewSpec.js index c910ba509b0d..83f9b3c12c9a 100644 --- a/test/ngRoute/directive/ngViewSpec.js +++ b/test/ngRoute/directive/ngViewSpec.js @@ -1,983 +1,1098 @@ 'use strict'; describe('ngView', function() { - var element; - beforeEach(module('ngRoute')); + describe('basics', function() { + var element; - beforeEach(module(function($provide) { - return function($rootScope, $compile, $animate) { - element = $compile('
          ')($rootScope); - }; - })); + beforeEach(module('ngRoute')); + beforeEach(module(function($provide) { + return function($rootScope, $compile, $animate) { + element = $compile('
          ')($rootScope); + }; + })); - afterEach(function() { - dealoc(element); - }); + + afterEach(function() { + dealoc(element); + }); - it('should do nothing when no routes are defined', - inject(function($rootScope, $compile, $location) { - $location.path('/unknown'); - $rootScope.$digest(); - expect(element.text()).toEqual(''); - })); + it('should do nothing when no routes are defined', + inject(function($rootScope, $compile, $location) { + $location.path('/unknown'); + $rootScope.$digest(); + expect(element.text()).toEqual(''); + })); - it('should instantiate controller after compiling the content', function() { - var log = [], controllerScope, - Ctrl = function($scope) { - controllerScope = $scope; - log.push('ctrl-init'); - }; + it('should instantiate controller after compiling the content', function() { + var log = [], controllerScope, + Ctrl = function($scope) { + controllerScope = $scope; + log.push('ctrl-init'); + }; - module(function($compileProvider, $routeProvider) { - $compileProvider.directive('compileLog', function() { - return { - compile: function() { - log.push('compile'); - } - }; + module(function($compileProvider, $routeProvider) { + $compileProvider.directive('compileLog', function() { + return { + compile: function() { + log.push('compile'); + } + }; + }); + + $routeProvider.when('/some', {templateUrl: '/tpl.html', controller: Ctrl}); }); - $routeProvider.when('/some', {templateUrl: '/tpl.html', controller: Ctrl}); + inject(function($route, $rootScope, $templateCache, $location) { + $templateCache.put('/tpl.html', [200, '
          partial
          ', {}]); + $location.path('/some'); + $rootScope.$digest(); + + expect(controllerScope.$parent).toBe($rootScope); + expect(controllerScope).toBe($route.current.scope); + expect(log).toEqual(['compile', 'ctrl-init']); + }); }); - inject(function($route, $rootScope, $templateCache, $location) { - $templateCache.put('/tpl.html', [200, '
          partial
          ', {}]); - $location.path('/some'); - $rootScope.$digest(); - expect(controllerScope.$parent).toBe($rootScope); - expect(controllerScope).toBe($route.current.scope); - expect(log).toEqual(['compile', 'ctrl-init']); - }); - }); + it('should instantiate the associated controller when an empty template is downloaded', function() { + var log = [], controllerScope, + Ctrl = function($scope) { + controllerScope = $scope; + log.push('ctrl-init'); + }; + + module(function($routeProvider) { + $routeProvider.when('/some', {templateUrl: '/tpl.html', controller: Ctrl}); + }); + inject(function($route, $rootScope, $templateCache, $location) { + $templateCache.put('/tpl.html', [200, '', {}]); + $location.path('/some'); - it('should instantiate the associated controller when an empty template is downloaded', function() { - var log = [], controllerScope, - Ctrl = function($scope) { - controllerScope = $scope; - log.push('ctrl-init'); - }; + expect(function() { + $rootScope.$digest(); + }).not.toThrow(); - module(function($routeProvider) { - $routeProvider.when('/some', {templateUrl: '/tpl.html', controller: Ctrl}); + expect(controllerScope).toBeDefined(); + }); }); - inject(function($route, $rootScope, $templateCache, $location) { - $templateCache.put('/tpl.html', [200, '', {}]); - $location.path('/some'); - expect(function() { + it('should instantiate controller with an alias', function() { + var log = [], controllerScope; + + function Ctrl($scope) { + this.name = 'alias'; + controllerScope = $scope; + } + + module(function($compileProvider, $routeProvider) { + $routeProvider.when('/some', {templateUrl: '/tpl.html', controller: Ctrl, controllerAs: 'ctrl'}); + }); + + inject(function($route, $rootScope, $templateCache, $location) { + $templateCache.put('/tpl.html', [200, '
          ', {}]); + $location.path('/some'); $rootScope.$digest(); - }).not.toThrow(); - expect(controllerScope).toBeDefined(); + expect(controllerScope.ctrl.name).toBe('alias'); + }); }); - }); - it('should instantiate controller with an alias', function() { - var log = [], controllerScope, - Ctrl = function($scope) { - this.name = 'alias'; - controllerScope = $scope; - }; + it('should support string controller declaration', function() { + var MyCtrl = jasmine.createSpy('MyCtrl'); - module(function($compileProvider, $routeProvider) { - $routeProvider.when('/some', {templateUrl: '/tpl.html', controller: Ctrl, controllerAs: 'ctrl'}); - }); + module(function($controllerProvider, $routeProvider) { + $controllerProvider.register('MyCtrl', ['$scope', MyCtrl]); + $routeProvider.when('/foo', {controller: 'MyCtrl', templateUrl: '/tpl.html'}); + }); - inject(function($route, $rootScope, $templateCache, $location) { - $templateCache.put('/tpl.html', [200, '
          ', {}]); - $location.path('/some'); - $rootScope.$digest(); + inject(function($route, $location, $rootScope, $templateCache) { + $templateCache.put('/tpl.html', [200, '
          ', {}]); + $location.path('/foo'); + $rootScope.$digest(); - expect(controllerScope.ctrl.name).toBe('alias'); + expect($route.current.controller).toBe('MyCtrl'); + expect(MyCtrl).toHaveBeenCalledWith(element.children().scope()); + }); }); - }); - it('should support string controller declaration', function() { - var MyCtrl = jasmine.createSpy('MyCtrl'); + it('should reference resolved locals in scope', function() { + module(function($routeProvider) { + $routeProvider.when('/foo', { + resolve: { + name: function() { + return 'shahar'; + } + }, + template: '
          {{$resolve.name}}
          ' + }); + }); - module(function($controllerProvider, $routeProvider) { - $controllerProvider.register('MyCtrl', ['$scope', MyCtrl]); - $routeProvider.when('/foo', {controller: 'MyCtrl', templateUrl: '/tpl.html'}); + inject(function($location, $rootScope) { + $location.path('/foo'); + $rootScope.$digest(); + expect(element.text()).toEqual('shahar'); + }); }); - inject(function($route, $location, $rootScope, $templateCache) { - $templateCache.put('/tpl.html', [200, '
          ', {}]); - $location.path('/foo'); - $rootScope.$digest(); - expect($route.current.controller).toBe('MyCtrl'); - expect(MyCtrl).toHaveBeenCalledWith(element.children().scope()); + it('should allow to provide an alias for resolved locals using resolveAs', function() { + module(function($routeProvider) { + $routeProvider.when('/foo', { + resolveAs: 'myResolve', + resolve: { + name: function() { + return 'shahar'; + } + }, + template: '
          {{myResolve.name}}
          ' + }); + }); + + inject(function($location, $rootScope) { + $location.path('/foo'); + $rootScope.$digest(); + expect(element.text()).toEqual('shahar'); + }); }); - }); - it('should load content via xhr when route changes', function() { - module(function($routeProvider) { - $routeProvider.when('/foo', {templateUrl: 'myUrl1'}); - $routeProvider.when('/bar', {templateUrl: 'myUrl2'}); - }); + it('should load content via xhr when route changes', function() { + module(function($routeProvider) { + $routeProvider.when('/foo', {templateUrl: 'myUrl1'}); + $routeProvider.when('/bar', {templateUrl: 'myUrl2'}); + }); - inject(function($rootScope, $compile, $httpBackend, $location, $route) { - expect(element.text()).toEqual(''); + inject(function($rootScope, $compile, $httpBackend, $location, $route) { + expect(element.text()).toEqual(''); - $location.path('/foo'); - $httpBackend.expect('GET', 'myUrl1').respond('
          {{1+3}}
          '); - $rootScope.$digest(); - $httpBackend.flush(); - expect(element.text()).toEqual('4'); + $location.path('/foo'); + $httpBackend.expect('GET', 'myUrl1').respond('
          {{1+3}}
          '); + $rootScope.$digest(); + $httpBackend.flush(); + expect(element.text()).toEqual('4'); - $location.path('/bar'); - $httpBackend.expect('GET', 'myUrl2').respond('angular is da best'); - $rootScope.$digest(); - $httpBackend.flush(); - expect(element.text()).toEqual('angular is da best'); + $location.path('/bar'); + $httpBackend.expect('GET', 'myUrl2').respond('angular is da best'); + $rootScope.$digest(); + $httpBackend.flush(); + expect(element.text()).toEqual('angular is da best'); + }); }); - }); - it('should use inline content route changes', function() { - module(function($routeProvider) { - $routeProvider.when('/foo', {template: '
          {{1+3}}
          '}); - $routeProvider.when('/bar', {template: 'angular is da best'}); - $routeProvider.when('/blank', {template: ''}); - }); + it('should use inline content route changes', function() { + module(function($routeProvider) { + $routeProvider.when('/foo', {template: '
          {{1+3}}
          '}); + $routeProvider.when('/bar', {template: 'AngularJS is da best'}); + $routeProvider.when('/blank', {template: ''}); + }); - inject(function($rootScope, $compile, $location, $route) { - expect(element.text()).toEqual(''); + inject(function($rootScope, $compile, $location, $route) { + expect(element.text()).toEqual(''); - $location.path('/foo'); - $rootScope.$digest(); - expect(element.text()).toEqual('4'); + $location.path('/foo'); + $rootScope.$digest(); + expect(element.text()).toEqual('4'); - $location.path('/bar'); - $rootScope.$digest(); - expect(element.text()).toEqual('angular is da best'); + $location.path('/bar'); + $rootScope.$digest(); + expect(element.text()).toEqual('AngularJS is da best'); - $location.path('/blank'); - $rootScope.$digest(); - expect(element.text()).toEqual(''); + $location.path('/blank'); + $rootScope.$digest(); + expect(element.text()).toEqual(''); + }); }); - }); - it('should remove all content when location changes to an unknown route', function() { - module(function($routeProvider) { - $routeProvider.when('/foo', {templateUrl: 'myUrl1'}); - }); + it('should remove all content when location changes to an unknown route', function() { + module(function($routeProvider) { + $routeProvider.when('/foo', {templateUrl: 'myUrl1'}); + }); - inject(function($rootScope, $compile, $location, $httpBackend, $route) { - $location.path('/foo'); - $httpBackend.expect('GET', 'myUrl1').respond('
          {{1+3}}
          '); - $rootScope.$digest(); - $httpBackend.flush(); - expect(element.text()).toEqual('4'); + inject(function($rootScope, $compile, $location, $httpBackend, $route) { + $location.path('/foo'); + $httpBackend.expect('GET', 'myUrl1').respond('
          {{1+3}}
          '); + $rootScope.$digest(); + $httpBackend.flush(); + expect(element.text()).toEqual('4'); - $location.path('/unknown'); - $rootScope.$digest(); - expect(element.text()).toEqual(''); + $location.path('/unknown'); + $rootScope.$digest(); + expect(element.text()).toEqual(''); + }); }); - }); - it('should chain scopes and propagate evals to the child scope', function() { - module(function($routeProvider) { - $routeProvider.when('/foo', {templateUrl: 'myUrl1'}); - }); + it('should chain scopes and propagate evals to the child scope', function() { + module(function($routeProvider) { + $routeProvider.when('/foo', {templateUrl: 'myUrl1'}); + }); - inject(function($rootScope, $compile, $location, $httpBackend, $route) { - $rootScope.parentVar = 'parent'; + inject(function($rootScope, $compile, $location, $httpBackend, $route) { + $rootScope.parentVar = 'parent'; - $location.path('/foo'); - $httpBackend.expect('GET', 'myUrl1').respond('
          {{parentVar}}
          '); - $rootScope.$digest(); - $httpBackend.flush(); - expect(element.text()).toEqual('parent'); + $location.path('/foo'); + $httpBackend.expect('GET', 'myUrl1').respond('
          {{parentVar}}
          '); + $rootScope.$digest(); + $httpBackend.flush(); + expect(element.text()).toEqual('parent'); - $rootScope.parentVar = 'new parent'; - $rootScope.$digest(); - expect(element.text()).toEqual('new parent'); + $rootScope.parentVar = 'new parent'; + $rootScope.$digest(); + expect(element.text()).toEqual('new parent'); + }); }); - }); - it('should be possible to nest ngView in ngInclude', function() { + it('should be possible to nest ngView in ngInclude', function() { - module(function($routeProvider) { - $routeProvider.when('/foo', {templateUrl: 'viewPartial.html'}); - }); + module(function($routeProvider) { + $routeProvider.when('/foo', {templateUrl: 'viewPartial.html'}); + }); - inject(function($httpBackend, $location, $route, $compile, $rootScope) { - $httpBackend.whenGET('includePartial.html').respond('view: '); - $httpBackend.whenGET('viewPartial.html').respond('content'); - $location.path('/foo'); + inject(function($httpBackend, $location, $route, $compile, $rootScope) { + $httpBackend.whenGET('includePartial.html').respond('view: '); + $httpBackend.whenGET('viewPartial.html').respond('content'); + $location.path('/foo'); - var elm = $compile( - '
          ' + - 'include: ' + - '
          ')($rootScope); - $rootScope.$digest(); - $httpBackend.flush(); + var elm = $compile( + '
          ' + + 'include: ' + + '
          ')($rootScope); + $rootScope.$digest(); + $httpBackend.flush(); - expect(elm.text()).toEqual('include: view: content'); - expect($route.current.templateUrl).toEqual('viewPartial.html'); - dealoc(elm); + expect(elm.text()).toEqual('include: view: content'); + expect($route.current.templateUrl).toEqual('viewPartial.html'); + dealoc(elm); + }); }); - }); - it('should initialize view template after the view controller was initialized even when ' + - 'templates were cached', function() { - //this is a test for a regression that was introduced by making the ng-view cache sync - function ParentCtrl($scope) { - $scope.log.push('parent'); - } + it('should initialize view template after the view controller was initialized even when ' + + 'templates were cached', function() { + //this is a test for a regression that was introduced by making the ng-view cache sync + function ParentCtrl($scope) { + $scope.log.push('parent'); + } - module(function($routeProvider) { - $routeProvider.when('/foo', {controller: ParentCtrl, templateUrl: 'viewPartial.html'}); - }); + module(function($routeProvider) { + $routeProvider.when('/foo', {controller: ParentCtrl, templateUrl: 'viewPartial.html'}); + }); - inject(function($rootScope, $compile, $location, $httpBackend, $route) { - $rootScope.log = []; + inject(function($rootScope, $compile, $location, $httpBackend, $route) { + $rootScope.log = []; - $rootScope.ChildCtrl = function($scope) { - $scope.log.push('child'); - }; + $rootScope.ChildCtrl = function($scope) { + $scope.log.push('child'); + }; - $location.path('/foo'); - $httpBackend.expect('GET', 'viewPartial.html'). - respond('
          ' + - '
          ' + - '
          '); - $rootScope.$apply(); - $httpBackend.flush(); + $location.path('/foo'); + $httpBackend.expect('GET', 'viewPartial.html'). + respond('
          ' + + '
          ' + + '
          '); + $rootScope.$apply(); + $httpBackend.flush(); - expect($rootScope.log).toEqual(['parent', 'init', 'child']); + expect($rootScope.log).toEqual(['parent', 'init', 'child']); - $location.path('/'); - $rootScope.$apply(); - expect($rootScope.log).toEqual(['parent', 'init', 'child']); + $location.path('/'); + $rootScope.$apply(); + expect($rootScope.log).toEqual(['parent', 'init', 'child']); - $rootScope.log = []; - $location.path('/foo'); - $rootScope.$apply(); + $rootScope.log = []; + $location.path('/foo'); + $rootScope.$apply(); - expect($rootScope.log).toEqual(['parent', 'init', 'child']); + expect($rootScope.log).toEqual(['parent', 'init', 'child']); + }); }); - }); - it('should discard pending xhr callbacks if a new route is requested before the current ' + - 'finished loading', function() { - // this is a test for a bad race condition that affected feedback + it('should discard pending xhr callbacks if a new route is requested before the current ' + + 'finished loading', function() { + // this is a test for a bad race condition that affected feedback - module(function($routeProvider) { - $routeProvider.when('/foo', {templateUrl: 'myUrl1'}); - $routeProvider.when('/bar', {templateUrl: 'myUrl2'}); - }); + module(function($routeProvider) { + $routeProvider.when('/foo', {templateUrl: 'myUrl1'}); + $routeProvider.when('/bar', {templateUrl: 'myUrl2'}); + }); - inject(function($route, $rootScope, $location, $httpBackend) { - expect(element.text()).toEqual(''); + inject(function($route, $rootScope, $location, $httpBackend) { + expect(element.text()).toEqual(''); - $location.path('/foo'); - $httpBackend.expect('GET', 'myUrl1').respond('
          {{1+3}}
          '); - $rootScope.$digest(); - $location.path('/bar'); - $httpBackend.expect('GET', 'myUrl2').respond('
          {{1+1}}
          '); - $rootScope.$digest(); - $httpBackend.flush(); // now that we have two requests pending, flush! + $location.path('/foo'); + $httpBackend.expect('GET', 'myUrl1').respond('
          {{1+3}}
          '); + $rootScope.$digest(); + $location.path('/bar'); + $httpBackend.expect('GET', 'myUrl2').respond('
          {{1+1}}
          '); + $rootScope.$digest(); + $httpBackend.flush(); // now that we have two requests pending, flush! - expect(element.text()).toEqual('2'); + expect(element.text()).toEqual('2'); + }); }); - }); - it('should be async even if served from cache', function() { - module(function($routeProvider) { - $routeProvider.when('/foo', {controller: angular.noop, templateUrl: 'myUrl1'}); - }); + it('should be async even if served from cache', function() { + module(function($routeProvider) { + $routeProvider.when('/foo', {controller: angular.noop, templateUrl: 'myUrl1'}); + }); - inject(function($route, $rootScope, $location, $templateCache) { - $templateCache.put('myUrl1', [200, 'my partial', {}]); - $location.path('/foo'); + inject(function($route, $rootScope, $location, $templateCache) { + $templateCache.put('myUrl1', [200, 'my partial', {}]); + $location.path('/foo'); - var called = 0; - // we want to assert only during first watch - $rootScope.$watch(function() { - if (!called) expect(element.text()).toBe(''); - called++; - }); + var called = 0; + // we want to assert only during first watch + $rootScope.$watch(function() { + if (!called) expect(element.text()).toBe(''); + called++; + }); - $rootScope.$digest(); - expect(element.text()).toBe('my partial'); + $rootScope.$digest(); + expect(element.text()).toBe('my partial'); + }); }); - }); - it('should fire $contentLoaded event when content compiled and linked', function() { - var log = []; - var logger = function(name) { - return function() { - log.push(name); + it('should fire $contentLoaded event when content compiled and linked', function() { + var log = []; + var logger = function(name) { + return function() { + log.push(name); + }; + }; + var Ctrl = function($scope) { + $scope.value = 'bound-value'; + log.push('init-ctrl'); }; - }; - var Ctrl = function($scope) { - $scope.value = 'bound-value'; - log.push('init-ctrl'); - }; - - module(function($routeProvider) { - $routeProvider.when('/foo', {templateUrl: 'tpl.html', controller: Ctrl}); - }); - inject(function($templateCache, $rootScope, $location) { - $rootScope.$on('$routeChangeStart', logger('$routeChangeStart')); - $rootScope.$on('$routeChangeSuccess', logger('$routeChangeSuccess')); - $rootScope.$on('$viewContentLoaded', logger('$viewContentLoaded')); + module(function($routeProvider) { + $routeProvider.when('/foo', {templateUrl: 'tpl.html', controller: Ctrl}); + }); - $templateCache.put('tpl.html', [200, '{{value}}', {}]); - $location.path('/foo'); - $rootScope.$digest(); + inject(function($templateCache, $rootScope, $location) { + $rootScope.$on('$routeChangeStart', logger('$routeChangeStart')); + $rootScope.$on('$routeChangeSuccess', logger('$routeChangeSuccess')); + $rootScope.$on('$viewContentLoaded', logger('$viewContentLoaded')); - expect(element.text()).toBe('bound-value'); - expect(log).toEqual([ - '$routeChangeStart', 'init-ctrl', '$viewContentLoaded', '$routeChangeSuccess' - ]); - }); - }); + $templateCache.put('tpl.html', [200, '{{value}}', {}]); + $location.path('/foo'); + $rootScope.$digest(); - it('should destroy previous scope', function() { - module(function($routeProvider) { - $routeProvider.when('/foo', {templateUrl: 'tpl.html'}); + expect(element.text()).toBe('bound-value'); + expect(log).toEqual([ + '$routeChangeStart', 'init-ctrl', '$viewContentLoaded', '$routeChangeSuccess' + ]); + }); }); - inject(function($templateCache, $rootScope, $location) { - $templateCache.put('tpl.html', [200, 'partial', {}]); + it('should destroy previous scope', function() { + module(function($routeProvider) { + $routeProvider.when('/foo', {templateUrl: 'tpl.html'}); + }); - expect($rootScope.$$childHead).toBeNull(); - expect($rootScope.$$childTail).toBeNull(); + inject(function($templateCache, $rootScope, $location) { + $templateCache.put('tpl.html', [200, 'partial', {}]); - $location.path('/foo'); - $rootScope.$digest(); + expect($rootScope.$$childHead).toBeNull(); + expect($rootScope.$$childTail).toBeNull(); - expect(element.text()).toBe('partial'); - expect($rootScope.$$childHead).not.toBeNull(); - expect($rootScope.$$childTail).not.toBeNull(); + $location.path('/foo'); + $rootScope.$digest(); - $location.path('/non/existing/route'); - $rootScope.$digest(); + expect(element.text()).toBe('partial'); + expect($rootScope.$$childHead).not.toBeNull(); + expect($rootScope.$$childTail).not.toBeNull(); - expect(element.text()).toBe(''); - expect($rootScope.$$childHead).toBeNull(); - expect($rootScope.$$childTail).toBeNull(); + $location.path('/non/existing/route'); + $rootScope.$digest(); + + expect(element.text()).toBe(''); + expect($rootScope.$$childHead).toBeNull(); + expect($rootScope.$$childTail).toBeNull(); + }); }); - }); - it('should destroy previous scope if multiple route changes occur before server responds', - function() { - var log = []; - var createCtrl = function(name) { - return function($scope) { - log.push('init-' + name); - $scope.$on('$destroy', function() {log.push('destroy-' + name);}); + it('should destroy previous scope if multiple route changes occur before server responds', + function() { + var log = []; + var createCtrl = function(name) { + return function($scope) { + log.push('init-' + name); + $scope.$on('$destroy', function() {log.push('destroy-' + name);}); + }; }; - }; - module(function($routeProvider) { - $routeProvider.when('/one', {templateUrl: 'one.html', controller: createCtrl('ctrl1')}); - $routeProvider.when('/two', {templateUrl: 'two.html', controller: createCtrl('ctrl2')}); - }); + module(function($routeProvider) { + $routeProvider.when('/one', {templateUrl: 'one.html', controller: createCtrl('ctrl1')}); + $routeProvider.when('/two', {templateUrl: 'two.html', controller: createCtrl('ctrl2')}); + }); - inject(function($httpBackend, $rootScope, $location) { - $httpBackend.whenGET('one.html').respond('content 1'); - $httpBackend.whenGET('two.html').respond('content 2'); + inject(function($httpBackend, $rootScope, $location) { + $httpBackend.whenGET('one.html').respond('content 1'); + $httpBackend.whenGET('two.html').respond('content 2'); - $location.path('/one'); - $rootScope.$digest(); - $location.path('/two'); - $rootScope.$digest(); + $location.path('/one'); + $rootScope.$digest(); + $location.path('/two'); + $rootScope.$digest(); - $httpBackend.flush(); - expect(element.text()).toBe('content 2'); - expect(log).toEqual(['init-ctrl2']); + $httpBackend.flush(); + expect(element.text()).toBe('content 2'); + expect(log).toEqual(['init-ctrl2']); - $location.path('/non-existing'); - $rootScope.$digest(); + $location.path('/non-existing'); + $rootScope.$digest(); - expect(element.text()).toBe(''); - expect(log).toEqual(['init-ctrl2', 'destroy-ctrl2']); + expect(element.text()).toBe(''); + expect(log).toEqual(['init-ctrl2', 'destroy-ctrl2']); - expect($rootScope.$$childHead).toBeNull(); - expect($rootScope.$$childTail).toBeNull(); + expect($rootScope.$$childHead).toBeNull(); + expect($rootScope.$$childTail).toBeNull(); + }); }); - }); - it('should $destroy scope after update and reload', function() { - // this is a regression of bug, where $route doesn't copy scope when only updating + it('should $destroy scope after update and reload', function() { + // this is a regression of bug, where $route doesn't copy scope when only updating - var log = []; + var log = []; - function logger(msg) { - return function() { - log.push(msg); - }; - } + function logger(msg) { + return function() { + log.push(msg); + }; + } - function createController(name) { - return function($scope) { - log.push('init-' + name); - $scope.$on('$destroy', logger('destroy-' + name)); - $scope.$on('$routeUpdate', logger('route-update')); - }; - } + function createController(name) { + return function($scope) { + log.push('init-' + name); + $scope.$on('$destroy', logger('destroy-' + name)); + $scope.$on('$routeUpdate', logger('route-update')); + }; + } - module(function($routeProvider) { - $routeProvider.when('/bar', {templateUrl: 'tpl.html', controller: createController('bar')}); - $routeProvider.when('/foo', { - templateUrl: 'tpl.html', - controller: createController('foo'), - reloadOnSearch: false + module(function($routeProvider) { + $routeProvider.when('/bar', {templateUrl: 'tpl.html', controller: createController('bar')}); + $routeProvider.when('/foo', { + templateUrl: 'tpl.html', + controller: createController('foo'), + reloadOnSearch: false + }); }); - }); - inject(function($templateCache, $location, $rootScope) { - $templateCache.put('tpl.html', [200, 'partial', {}]); + inject(function($templateCache, $location, $rootScope) { + $templateCache.put('tpl.html', [200, 'partial', {}]); - $location.url('https://github.com/foo'); - $rootScope.$digest(); - expect(log).toEqual(['init-foo']); + $location.url('https://github.com/foo'); + $rootScope.$digest(); + expect(log).toEqual(['init-foo']); - $location.search({q: 'some'}); - $rootScope.$digest(); - expect(log).toEqual(['init-foo', 'route-update']); + $location.search({q: 'some'}); + $rootScope.$digest(); + expect(log).toEqual(['init-foo', 'route-update']); - $location.url('https://github.com/bar'); - $rootScope.$digest(); - expect(log).toEqual(['init-foo', 'route-update', 'destroy-foo', 'init-bar']); + $location.url('https://github.com/bar'); + $rootScope.$digest(); + expect(log).toEqual(['init-foo', 'route-update', 'destroy-foo', 'init-bar']); + }); }); - }); - it('should evaluate onload expression after linking the content', function() { - module(function($routeProvider) { - $routeProvider.when('/foo', {templateUrl: 'tpl.html'}); - }); + it('should evaluate onload expression after linking the content', function() { + module(function($routeProvider) { + $routeProvider.when('/foo', {templateUrl: 'tpl.html'}); + }); - inject(function($templateCache, $location, $rootScope) { - $templateCache.put('tpl.html', [200, '{{1+1}}', {}]); - $rootScope.load = jasmine.createSpy('onload'); + inject(function($templateCache, $location, $rootScope) { + $templateCache.put('tpl.html', [200, '{{1+1}}', {}]); + $rootScope.load = jasmine.createSpy('onload'); - $location.url('https://github.com/foo'); - $rootScope.$digest(); - expect($rootScope.load).toHaveBeenCalledOnce(); + $location.url('https://github.com/foo'); + $rootScope.$digest(); + expect($rootScope.load).toHaveBeenCalledOnce(); + }); }); - }); - it('should set $scope and $controllerController on the view elements (except for non-element nodes)', function() { - function MyCtrl($scope) { - $scope.state = 'WORKS'; - $scope.ctrl = this; - } + it('should set $scope and $controllerController on the view elements (except for non-element nodes)', function() { + function MyCtrl($scope) { + $scope.state = 'WORKS'; + $scope.ctrl = this; + } - module(function($routeProvider) { - $routeProvider.when('/foo', {templateUrl: 'tpl.html', controller: MyCtrl}); - }); + module(function($routeProvider) { + $routeProvider.when('/foo', {templateUrl: 'tpl.html', controller: MyCtrl}); + }); - inject(function($templateCache, $location, $rootScope, $route) { - // in the template the white-space before the div is an intentional non-element node, - // a text might get wrapped into span so it's safer to just use white space - $templateCache.put('tpl.html', [200, ' \n
          {{state}}
          ', {}]); + inject(function($templateCache, $location, $rootScope, $route) { + // in the template the white-space before the div is an intentional non-element node, + // a text might get wrapped into span so it's safer to just use white space + $templateCache.put('tpl.html', [200, ' \n
          {{state}}
          ', {}]); - $location.url('https://github.com/foo'); - $rootScope.$digest(); - // using toMatch because in IE8+jquery the space doesn't get preserved. jquery bug? - expect(element.text()).toMatch(/\s*WORKS/); + $location.url('https://github.com/foo'); + $rootScope.$digest(); + expect(element.text()).toEqual(' \n WORKS'); - var div = element.find('div'); - expect(div.parent()[0].nodeName.toUpperCase()).toBeOneOf('NG:VIEW', 'VIEW'); + var div = element.find('div'); + expect(div.parent()[0].nodeName.toUpperCase()).toBeOneOf('NG:VIEW', 'VIEW'); - expect(div.scope()).toBe($route.current.scope); - expect(div.scope().hasOwnProperty('state')).toBe(true); - expect(div.scope().state).toEqual('WORKS'); + expect(div.scope()).toBe($route.current.scope); + expect(div.scope().hasOwnProperty('state')).toBe(true); + expect(div.scope().state).toEqual('WORKS'); - expect(div.controller()).toBe($route.current.scope.ctrl); + expect(div.controller()).toBe($route.current.scope.ctrl); + }); }); - }); - it('should not set $scope or $controllerController on top level text elements in the view', function() { - function MyCtrl($scope) {} + it('should not set $scope or $controllerController on top level text elements in the view', function() { + function MyCtrl($scope) {} - module(function($routeProvider) { - $routeProvider.when('/foo', {templateUrl: 'tpl.html', controller: MyCtrl}); - }); + module(function($routeProvider) { + $routeProvider.when('/foo', {templateUrl: 'tpl.html', controller: MyCtrl}); + }); - inject(function($templateCache, $location, $rootScope, $route) { - $templateCache.put('tpl.html', '
          '); - $location.url('https://github.com/foo'); - $rootScope.$digest(); + inject(function($templateCache, $location, $rootScope, $route) { + $templateCache.put('tpl.html', '
          '); + $location.url('https://github.com/foo'); + $rootScope.$digest(); - angular.forEach(element.contents(), function(node) { - if (node.nodeType == 3 /* text node */) { - expect(angular.element(node).scope()).not.toBe($route.current.scope); - expect(angular.element(node).controller()).not.toBeDefined(); - } else if (node.nodeType == 8 /* comment node */) { - expect(angular.element(node).scope()).toBe(element.scope()); - expect(angular.element(node).controller()).toBe(element.controller()); - } else { - expect(angular.element(node).scope()).toBe($route.current.scope); - expect(angular.element(node).controller()).toBeDefined(); - } + angular.forEach(element.contents(), function(node) { + if (node.nodeType === 3 /* text node */) { + expect(angular.element(node).scope()).not.toBe($route.current.scope); + expect(angular.element(node).controller()).not.toBeDefined(); + } else if (node.nodeType === 8 /* comment node */) { + expect(angular.element(node).scope()).toBe(element.scope()); + expect(angular.element(node).controller()).toBe(element.controller()); + } else { + expect(angular.element(node).scope()).toBe($route.current.scope); + expect(angular.element(node).controller()).toBeDefined(); + } + }); }); }); - }); -}); -describe('ngView and transcludes', function() { - var element, directive; - beforeEach(module('ngRoute', function($compileProvider) { - element = null; - directive = $compileProvider.directive; - })); + it('should not trigger a digest when the view is changed', function() { + module(function($routeProvider) { + $routeProvider.when('/foo', {templateUrl: 'myUrl1'}); + $routeProvider.when('/bar', {templateUrl: 'myUrl2'}); + }); - afterEach(function() { - if (element) { - dealoc(element); - } - }); + inject(function($$rAF, $templateCache, $rootScope, $compile, $timeout, $location, $httpBackend) { + var spy = spyOn($rootScope, '$digest').and.callThrough(); - it('should allow access to directive controller from children when used in a replace template', function() { - var controller; - module(function($routeProvider) { - $routeProvider.when('/view', {templateUrl: 'view.html'}); - directive('template', function() { - return { - template: '
          ', - replace: true, - controller: function() { - this.flag = true; - } - }; - }); + $templateCache.put('myUrl1', 'my template content'); + $templateCache.put('myUrl2', 'my other template content'); - directive('test', function() { - return { - require: '^template', - link: function(scope, el, attr, ctrl) { - controller = ctrl; - } - }; + $location.path('/foo'); + $rootScope.$digest(); + + // The animation completion is async even without actual animations + $$rAF.flush(); + expect(element.text()).toEqual('my template content'); + + $location.path('/bar'); + $rootScope.$digest(); + spy.calls.reset(); + + $$rAF.flush(); + expect(element.text()).toEqual('my other template content'); + + expect(spy).not.toHaveBeenCalled(); + // A digest may have been triggered asynchronously, so check the queue + $timeout.verifyNoPendingTasks(); }); }); - inject(function($compile, $rootScope, $httpBackend, $location) { - $httpBackend.expectGET('view.html').respond('
          '); - element = $compile('
          ')($rootScope); - $location.url('https://github.com/view'); - $rootScope.$apply(); - $httpBackend.flush(); - expect(controller.flag).toBe(true); - }); + }); - it("should compile its content correctly (although we remove it later)", function() { - var testElement; - module(function($compileProvider, $routeProvider) { - $routeProvider.when('/view', {template: ' '}); - var directive = $compileProvider.directive; - directive('test', function() { - return { - link: function(scope, element) { - testElement = element; - } - }; - }); - }); - inject(function($compile, $rootScope, $location) { - element = $compile('
          ')($rootScope); - $location.url('https://github.com/view'); - $rootScope.$apply(); - expect(testElement[0].nodeName).toBe('DIV'); + describe('and transcludes', function() { + var element, directive; + + beforeEach(module('ngRoute', function($compileProvider) { + element = null; + directive = $compileProvider.directive; + })); + + afterEach(function() { + if (element) { + dealoc(element); + } }); - }); + it('should allow access to directive controller from children when used in a replace template', function() { + var controller; + module(function($routeProvider) { + $routeProvider.when('/view', {templateUrl: 'view.html'}); + directive('template', function() { + return { + template: '
          ', + replace: true, + controller: function() { + this.flag = true; + } + }; + }); - it('should link directives on the same element after the content has been loaded', function() { - var contentOnLink; - module(function($compileProvider, $routeProvider) { - $routeProvider.when('/view', {template: 'someContent'}); - $compileProvider.directive('test', function() { - return { - link: function(scope, element) { - contentOnLink = element.text(); - } - }; + directive('test', function() { + return { + require: '^template', + link: function(scope, el, attr, ctrl) { + controller = ctrl; + } + }; + }); + }); + inject(function($compile, $rootScope, $httpBackend, $location) { + $httpBackend.expectGET('view.html').respond('
          '); + element = $compile('
          ')($rootScope); + $location.url('https://github.com/view'); + $rootScope.$apply(); + $httpBackend.flush(); + expect(controller.flag).toBe(true); }); }); - inject(function($compile, $rootScope, $location) { - element = $compile('
          ')($rootScope); - $location.url('https://github.com/view'); - $rootScope.$apply(); - expect(contentOnLink).toBe('someContent'); - }); - }); - it('should add the content to the element before compiling it', function() { - var root; - module(function($compileProvider, $routeProvider) { - $routeProvider.when('/view', {template: ''}); - $compileProvider.directive('test', function() { - return { - link: function(scope, element) { - root = element.parent().parent(); - } - }; + it('should compile its content correctly (although we remove it later)', function() { + var testElement; + module(function($compileProvider, $routeProvider) { + $routeProvider.when('/view', {template: ' '}); + var directive = $compileProvider.directive; + directive('test', function() { + return { + link: function(scope, element) { + testElement = element; + } + }; + }); + }); + inject(function($compile, $rootScope, $location) { + element = $compile('
          ')($rootScope); + $location.url('https://github.com/view'); + $rootScope.$apply(); + expect(testElement[0].nodeName).toBe('DIV'); }); + }); - inject(function($compile, $rootScope, $location) { - element = $compile('
          ')($rootScope); - $location.url('https://github.com/view'); - $rootScope.$apply(); - expect(root[0]).toBe(element[0]); + + it('should link directives on the same element after the content has been loaded', function() { + var contentOnLink; + module(function($compileProvider, $routeProvider) { + $routeProvider.when('/view', {template: 'someContent'}); + $compileProvider.directive('test', function() { + return { + link: function(scope, element) { + contentOnLink = element.text(); + } + }; + }); + }); + inject(function($compile, $rootScope, $location) { + element = $compile('
          ')($rootScope); + $location.url('https://github.com/view'); + $rootScope.$apply(); + expect(contentOnLink).toBe('someContent'); + }); }); - }); -}); -describe('ngView animations', function() { - var body, element, $rootElement; - - beforeEach(module('ngRoute')); - - function html(content) { - $rootElement.html(content); - body.append($rootElement); - element = $rootElement.children().eq(0); - return element; - } - - beforeEach(module(function() { - // we need to run animation on attached elements; - return function(_$rootElement_) { - $rootElement = _$rootElement_; - body = angular.element(document.body); - }; - })); - - afterEach(function() { - dealoc(body); - dealoc(element); + it('should add the content to the element before compiling it', function() { + var root; + module(function($compileProvider, $routeProvider) { + $routeProvider.when('/view', {template: ''}); + $compileProvider.directive('test', function() { + return { + link: function(scope, element) { + root = element.parent().parent(); + } + }; + }); + }); + inject(function($compile, $rootScope, $location) { + element = $compile('
          ')($rootScope); + $location.url('https://github.com/view'); + $rootScope.$apply(); + expect(root[0]).toBe(element[0]); + }); + }); }); + describe('animations', function() { + var body, element, $rootElement; - beforeEach(module(function($provide, $routeProvider) { - $routeProvider.when('/foo', {controller: angular.noop, templateUrl: '/foo.html'}); - $routeProvider.when('/bar', {controller: angular.noop, templateUrl: '/bar.html'}); - return function($templateCache) { - $templateCache.put('/foo.html', [200, '
          data
          ', {}]); - $templateCache.put('/bar.html', [200, '
          data2
          ', {}]); - }; - })); + beforeEach(module('ngRoute')); - describe('hooks', function() { - beforeEach(module('ngAnimate')); - beforeEach(module('ngAnimateMock')); + function html(content) { + $rootElement.html(content); + body.append($rootElement); + element = $rootElement.children().eq(0); + return element; + } - it('should fire off the enter animation', - inject(function($compile, $rootScope, $location, $timeout, $animate) { - element = $compile(html('
          '))($rootScope); + beforeEach(module(function() { + // we need to run animation on attached elements; + return function(_$rootElement_) { + $rootElement = _$rootElement_; + body = angular.element(window.document.body); + }; + })); - $location.path('/foo'); - $rootScope.$digest(); + afterEach(function() { + dealoc(body); + dealoc(element); + }); - var animation = $animate.queue.pop(); - expect(animation.event).toBe('enter'); - })); - it('should fire off the leave animation', - inject(function($compile, $rootScope, $location, $templateCache, $timeout, $animate) { + beforeEach(module(function($provide, $routeProvider) { + $routeProvider.when('/foo', {controller: angular.noop, templateUrl: '/foo.html'}); + $routeProvider.when('/bar', {controller: angular.noop, templateUrl: '/bar.html'}); + return function($templateCache) { + $templateCache.put('/foo.html', [200, '
          data
          ', {}]); + $templateCache.put('/bar.html', [200, '
          data2
          ', {}]); + }; + })); - var item; - $templateCache.put('/foo.html', [200, '
          foo
          ', {}]); - element = $compile(html('
          '))($rootScope); + describe('hooks', function() { + beforeEach(module('ngAnimate')); + beforeEach(module('ngAnimateMock')); - $location.path('/foo'); - $rootScope.$digest(); + it('should fire off the enter animation', + inject(function($compile, $rootScope, $location, $timeout, $animate) { + element = $compile(html('
          '))($rootScope); - $animate.triggerCallbacks(); + $location.path('/foo'); + $rootScope.$digest(); - $location.path('/'); - $rootScope.$digest(); + var animation = $animate.queue.pop(); + expect(animation.event).toBe('enter'); + })); - var animation = $animate.queue.pop(); - expect(animation.event).toBe('leave'); - })); + it('should fire off the leave animation', + inject(function($compile, $rootScope, $location, $templateCache, $timeout, $animate) { - it('should animate two separate ngView elements', - inject(function($compile, $rootScope, $templateCache, $location, $animate) { var item; - $rootScope.tpl = 'one'; + $templateCache.put('/foo.html', [200, '
          foo
          ', {}]); element = $compile(html('
          '))($rootScope); - $rootScope.$digest(); $location.path('/foo'); $rootScope.$digest(); - //we don't care about the enter animation for the first element - $animate.queue.pop(); - $location.path('/bar'); + $location.path('/'); $rootScope.$digest(); - var animationB = $animate.queue.pop(); - expect(animationB.event).toBe('leave'); - var itemB = animationB.args[0]; + var animation = $animate.queue.pop(); + expect(animation.event).toBe('leave'); + })); - var animationA = $animate.queue.pop(); - expect(animationA.event).toBe('enter'); - var itemA = animationA.args[0]; + it('should animate two separate ngView elements', + inject(function($compile, $rootScope, $templateCache, $location, $animate) { + var item; + $rootScope.tpl = 'one'; + element = $compile(html('
          '))($rootScope); + $rootScope.$digest(); - expect(itemA).not.toEqual(itemB); - }) - ); + $location.path('/foo'); + $rootScope.$digest(); - it('should render ngClass on ngView', - inject(function($compile, $rootScope, $templateCache, $animate, $location, $timeout) { + //we don't care about the enter animation for the first element + $animate.queue.pop(); - var item; - $rootScope.tpl = 'one'; - $rootScope.klass = 'classy'; - element = $compile(html('
          '))($rootScope); - $rootScope.$digest(); + $location.path('/bar'); + $rootScope.$digest(); - $location.path('/foo'); - $rootScope.$digest(); + var animationB = $animate.queue.pop(); + expect(animationB.event).toBe('leave'); + var itemB = animationB.args[0]; - //we don't care about the enter animation - $animate.queue.shift(); + var animationA = $animate.queue.pop(); + expect(animationA.event).toBe('enter'); + var itemA = animationA.args[0]; - var animation = $animate.queue.shift(); - expect(animation.event).toBe('addClass'); + expect(itemA).not.toEqual(itemB); + }) + ); - item = animation.element; - expect(item.hasClass('classy')).toBe(true); + it('should render ngClass on ngView', + inject(function($compile, $rootScope, $templateCache, $animate, $location) { - $rootScope.klass = 'boring'; - $rootScope.$digest(); + var item; + $rootScope.tpl = 'one'; + $rootScope.klass = 'classy'; + element = $compile(html('
          '))($rootScope); + $rootScope.$digest(); - expect($animate.queue.shift().event).toBe('addClass'); - expect($animate.queue.shift().event).toBe('removeClass'); + $location.path('/foo'); + $rootScope.$digest(); + $animate.flush(); - $animate.triggerReflow(); + //we don't care about the enter animation + $animate.queue.shift(); - expect(item.hasClass('classy')).toBe(false); - expect(item.hasClass('boring')).toBe(true); + var animation = $animate.queue.shift(); + expect(animation.event).toBe('addClass'); - $location.path('/bar'); - $rootScope.$digest(); + item = animation.element; + expect(item.hasClass('classy')).toBe(true); - //we don't care about the enter animation - $animate.queue.shift(); + $rootScope.klass = 'boring'; + $rootScope.$digest(); - animation = $animate.queue.shift(); - item = animation.element; - expect(animation.event).toBe('leave'); + expect($animate.queue.shift().event).toBe('addClass'); + expect($animate.queue.shift().event).toBe('removeClass'); - expect($animate.queue.shift().event).toBe('addClass'); + $animate.flush(); - expect(item.hasClass('boring')).toBe(true); - }) - ); + expect(item.hasClass('classy')).toBe(false); + expect(item.hasClass('boring')).toBe(true); - it('should not double compile when the route changes', function() { + $location.path('/bar'); + $rootScope.$digest(); - var window; - module(function($routeProvider, $animateProvider, $provide) { - $routeProvider.when('/foo', {template: '
          {{i}}
          '}); - $routeProvider.when('/bar', {template: '
          {{i}}
          '}); - $animateProvider.register('.my-animation', function() { - return { - leave: function(element, done) { - done(); - } - }; + //we don't care about the enter animation + $animate.queue.shift(); + + animation = $animate.queue.shift(); + item = animation.element; + expect(animation.event).toBe('leave'); + + expect($animate.queue.shift().event).toBe('addClass'); + + expect(item.hasClass('boring')).toBe(true); + }) + ); + + it('should not double compile when the route changes', function() { + + var window; + module(function($routeProvider, $animateProvider, $provide) { + $routeProvider.when('/foo', {template: '
          {{i}}
          '}); + $routeProvider.when('/bar', {template: '
          {{i}}
          '}); + $animateProvider.register('.my-animation', function() { + return { + leave: function(element, done) { + done(); + } + }; + }); }); - }); - inject(function($rootScope, $compile, $location, $route, $timeout, $rootElement, $sniffer, $animate) { - element = $compile(html('
          '))($rootScope); - $animate.enabled(true); + inject(function($rootScope, $compile, $location, $route, $timeout, $rootElement, $sniffer, $animate) { + element = $compile(html('
          '))($rootScope); + $animate.enabled(true); - $location.path('/foo'); - $rootScope.$digest(); + $location.path('/foo'); + $rootScope.$digest(); - expect($animate.queue.shift().event).toBe('enter'); //ngView - expect($animate.queue.shift().event).toBe('enter'); //repeat 1 - expect($animate.queue.shift().event).toBe('enter'); //repeat 2 + expect($animate.queue.shift().event).toBe('enter'); //ngView + expect($animate.queue.shift().event).toBe('enter'); //repeat 1 + expect($animate.queue.shift().event).toBe('enter'); //repeat 2 - expect(element.text()).toEqual('12'); + expect(element.text()).toEqual('12'); - $location.path('/bar'); - $rootScope.$digest(); + $location.path('/bar'); + $rootScope.$digest(); - expect($animate.queue.shift().event).toBe('enter'); //ngView new - expect($animate.queue.shift().event).toBe('leave'); //ngView old + expect($animate.queue.shift().event).toBe('enter'); //ngView new + expect($animate.queue.shift().event).toBe('leave'); //ngView old - $rootScope.$digest(); + $rootScope.$digest(); - expect($animate.queue.shift().event).toBe('enter'); //ngRepeat 3 - expect($animate.queue.shift().event).toBe('enter'); //ngRepeat 4 + expect($animate.queue.shift().event).toBe('enter'); //ngRepeat 3 + expect($animate.queue.shift().event).toBe('enter'); //ngRepeat 4 - expect(element.text()).toEqual('34'); + $animate.flush(); - function n(text) { - return text.replace(/\r\n/m, '').replace(/\r\n/m, ''); - } + expect(element.text()).toEqual('34'); + + function n(text) { + return text.replace(/\r\n/m, '').replace(/\r\n/m, ''); + } + }); }); + + it('should destroy the previous leave animation if a new one takes place', + inject(function($compile, $rootScope, $animate, $location, $timeout) { + var $scope = $rootScope.$new(); + element = $compile(html( + '
          ' + + '
          ' + + '
          ' + ))($scope); + + $scope.$apply('value = true'); + + $location.path('/bar'); + $rootScope.$digest(); + + var destroyed, inner = element.children(0); + inner.on('$destroy', function() { + destroyed = true; + }); + + $location.path('/foo'); + $rootScope.$digest(); + + $location.path('/bar'); + $rootScope.$digest(); + + $location.path('/bar'); + $rootScope.$digest(); + + expect(destroyed).toBe(true); + }) + ); }); - it('should destroy the previous leave animation if a new one takes place', - inject(function($compile, $rootScope, $animate, $location, $timeout) { - var $scope = $rootScope.$new(); - element = $compile(html( - '
          ' + - '
          ' + - '
          ' - ))($scope); - $scope.$apply('value = true'); + describe('autoscroll', function() { + var autoScrollSpy; - $location.path('/bar'); - $rootScope.$digest(); + function spyOnAnchorScroll() { + return function($provide, $routeProvider) { + autoScrollSpy = jasmine.createSpy('$anchorScroll'); + $provide.value('$anchorScroll', autoScrollSpy); + $routeProvider.when('/foo', { + controller: angular.noop, + template: '
          ' + }); + }; + } - var destroyed, inner = element.children(0); - inner.on('$destroy', function() { - destroyed = true; - }); + function spyOnAnimateEnter() { + return function($animate) { + spyOn($animate, 'enter').and.callThrough(); + }; + } - $location.path('/foo'); - $rootScope.$digest(); + function compileAndLink(tpl) { + return function($compile, $rootScope, $location) { + element = $compile(tpl)($rootScope); + }; + } - $location.path('/bar'); + beforeEach(module(spyOnAnchorScroll(), 'ngAnimateMock')); + beforeEach(inject(spyOnAnimateEnter())); + + it('should call $anchorScroll if autoscroll attribute is present', inject( + compileAndLink('
          '), + function($rootScope, $animate, $timeout, $location) { + + $location.path('/foo'); $rootScope.$digest(); - $location.path('/bar'); + $animate.flush(); $rootScope.$digest(); - expect(destroyed).toBe(true); - }) - ); - }); + expect($animate.queue.shift().event).toBe('enter'); + expect(autoScrollSpy).toHaveBeenCalledOnce(); + })); - describe('autoscroll', function() { - var autoScrollSpy; + it('should call $anchorScroll if autoscroll evaluates to true', inject( + compileAndLink('
          '), + function($rootScope, $animate, $timeout, $location) { - function spyOnAnchorScroll() { - return function($provide, $routeProvider) { - autoScrollSpy = jasmine.createSpy('$anchorScroll'); - $provide.value('$anchorScroll', autoScrollSpy); - $routeProvider.when('/foo', { - controller: angular.noop, - template: '
          ' - }); - }; - } + $rootScope.value = true; + $location.path('/foo'); + $rootScope.$digest(); - function spyOnAnimateEnter() { - return function($animate) { - spyOn($animate, 'enter').andCallThrough(); - }; - } + $animate.flush(); + $rootScope.$digest(); - function compileAndLink(tpl) { - return function($compile, $rootScope, $location) { - element = $compile(tpl)($rootScope); - }; - } + expect($animate.queue.shift().event).toBe('enter'); + expect(autoScrollSpy).toHaveBeenCalledOnce(); + })); - beforeEach(module(spyOnAnchorScroll(), 'ngAnimateMock')); - beforeEach(inject(spyOnAnimateEnter())); - it('should call $anchorScroll if autoscroll attribute is present', inject( - compileAndLink('
          '), - function($rootScope, $animate, $timeout, $location) { + it('should not call $anchorScroll if autoscroll attribute is not present', inject( + compileAndLink('
          '), + function($rootScope, $location, $animate, $timeout) { - $location.path('/foo'); - $rootScope.$digest(); - expect($animate.queue.shift().event).toBe('enter'); - $animate.triggerCallbacks(); + $location.path('/foo'); + $rootScope.$digest(); + expect($animate.queue.shift().event).toBe('enter'); - expect(autoScrollSpy).toHaveBeenCalledOnce(); - })); + expect(autoScrollSpy).not.toHaveBeenCalled(); + })); - it('should call $anchorScroll if autoscroll evaluates to true', inject( - compileAndLink('
          '), - function($rootScope, $animate, $timeout, $location) { + it('should not call $anchorScroll if autoscroll evaluates to false', inject( + compileAndLink('
          '), + function($rootScope, $location, $animate, $timeout) { - $rootScope.value = true; - $location.path('/foo'); - $rootScope.$digest(); - expect($animate.queue.shift().event).toBe('enter'); - $animate.triggerCallbacks(); + $rootScope.value = false; + $location.path('/foo'); + $rootScope.$digest(); + expect($animate.queue.shift().event).toBe('enter'); - expect(autoScrollSpy).toHaveBeenCalledOnce(); - })); + expect(autoScrollSpy).not.toHaveBeenCalled(); + })); - it('should not call $anchorScroll if autoscroll attribute is not present', inject( - compileAndLink('
          '), + it('should only call $anchorScroll after the "enter" animation completes', inject( + compileAndLink('
          '), function($rootScope, $location, $animate, $timeout) { + $location.path('/foo'); - $location.path('/foo'); - $rootScope.$digest(); - expect($animate.queue.shift().event).toBe('enter'); - $animate.triggerCallbacks(); + expect($animate.enter).not.toHaveBeenCalled(); + $rootScope.$digest(); - expect(autoScrollSpy).not.toHaveBeenCalled(); - })); + expect(autoScrollSpy).not.toHaveBeenCalled(); + expect($animate.queue.shift().event).toBe('enter'); - it('should not call $anchorScroll if autoscroll evaluates to false', inject( - compileAndLink('
          '), - function($rootScope, $location, $animate, $timeout) { + $animate.flush(); + $rootScope.$digest(); - $rootScope.value = false; - $location.path('/foo'); - $rootScope.$digest(); - expect($animate.queue.shift().event).toBe('enter'); - $animate.triggerCallbacks(); + expect($animate.enter).toHaveBeenCalledOnce(); + expect(autoScrollSpy).toHaveBeenCalledOnce(); + } + )); + }); + }); - expect(autoScrollSpy).not.toHaveBeenCalled(); - })); + describe('in async template', function() { + beforeEach(module('ngRoute')); + beforeEach(module(function($compileProvider, $provide, $routeProvider) { + $compileProvider.directive('asyncView', function() { + return {templateUrl: 'async-view.html'}; + }); + $provide.decorator('$templateRequest', function($timeout) { + return function() { + return $timeout(angular.identity, 500, false, ''); + }; + }); - it('should only call $anchorScroll after the "enter" animation completes', inject( - compileAndLink('
          '), - function($rootScope, $location, $animate, $timeout) { - $location.path('/foo'); + $routeProvider.when('/', {template: 'Hello, world!'}); + })); - expect($animate.enter).not.toHaveBeenCalled(); - $rootScope.$digest(); - expect(autoScrollSpy).not.toHaveBeenCalled(); + it('should work correctly upon initial page load', + // Injecting `$location` here is necessary, so that it gets instantiated early + inject(function($compile, $location, $rootScope, $timeout) { + var elem = $compile('')($rootScope); + $rootScope.$digest(); + $timeout.flush(500); - expect($animate.queue.shift().event).toBe('enter'); - $animate.triggerCallbacks(); + expect(elem.text()).toBe('Hello, world!'); - expect($animate.enter).toHaveBeenCalledOnce(); - expect(autoScrollSpy).toHaveBeenCalledOnce(); - } - )); + dealoc(elem); + }) + ); }); }); diff --git a/test/ngRoute/routeParamsSpec.js b/test/ngRoute/routeParamsSpec.js index 7c10a922302d..88b27dd8409d 100644 --- a/test/ngRoute/routeParamsSpec.js +++ b/test/ngRoute/routeParamsSpec.js @@ -72,10 +72,50 @@ describe('$routeParams', function() { $location.path('/qux//bazValue'); $rootScope.$digest(); - expect($routeParams).toEqual({baz: 'bazValue', bar: undefined}); + expect($routeParams).toEqual({baz: 'bazValue'}); }); }); + it('should correctly extract path params containing hashes and/or question marks', function() { + module(function($routeProvider) { + $routeProvider.when('/foo/:bar', {}); + $routeProvider.when('/zoo/:bar/:baz/:qux', {}); + }); + + inject(function($location, $rootScope, $routeParams) { + $location.path('/foo/bar?baz'); + $rootScope.$digest(); + expect($routeParams).toEqual({bar: 'bar?baz'}); + + $location.path('/foo/bar?baz=val'); + $rootScope.$digest(); + expect($routeParams).toEqual({bar: 'bar?baz=val'}); + + $location.path('/foo/bar#baz'); + $rootScope.$digest(); + expect($routeParams).toEqual({bar: 'bar#baz'}); + + $location.path('/foo/bar?baz#qux'); + $rootScope.$digest(); + expect($routeParams).toEqual({bar: 'bar?baz#qux'}); + + $location.path('/foo/bar?baz=val#qux'); + $rootScope.$digest(); + expect($routeParams).toEqual({bar: 'bar?baz=val#qux'}); + + $location.path('/foo/bar#baz?qux'); + $rootScope.$digest(); + expect($routeParams).toEqual({bar: 'bar#baz?qux'}); + + $location.path('/zoo/bar?p1=v1#h1/baz?p2=v2#h2/qux?p3=v3#h3'); + $rootScope.$digest(); + expect($routeParams).toEqual({ + bar: 'bar?p1=v1#h1', + baz: 'baz?p2=v2#h2', + qux: 'qux?p3=v3#h3' + }); + }); + }); }); diff --git a/test/ngRoute/routeSpec.js b/test/ngRoute/routeSpec.js index 368dadf3c9ae..fa31d4124651 100644 --- a/test/ngRoute/routeSpec.js +++ b/test/ngRoute/routeSpec.js @@ -1,5 +1,58 @@ 'use strict'; +describe('$routeProvider', function() { + var $routeProvider; + + beforeEach(module('ngRoute')); + beforeEach(module(function(_$routeProvider_) { + $routeProvider = _$routeProvider_; + $routeProvider.when('/foo', {template: 'Hello, world!'}); + })); + + + it('should support enabling/disabling automatic instantiation upon initial load', + inject(function() { + expect($routeProvider.eagerInstantiationEnabled(true)).toBe($routeProvider); + expect($routeProvider.eagerInstantiationEnabled()).toBe(true); + + expect($routeProvider.eagerInstantiationEnabled(false)).toBe($routeProvider); + expect($routeProvider.eagerInstantiationEnabled()).toBe(false); + + expect($routeProvider.eagerInstantiationEnabled(true)).toBe($routeProvider); + expect($routeProvider.eagerInstantiationEnabled()).toBe(true); + }) + ); + + + it('should automatically instantiate `$route` upon initial load', function() { + inject(function($location, $rootScope) { + $location.path('/foo'); + $rootScope.$digest(); + }); + + inject(function($route) { + expect($route.current).toBeDefined(); + }); + }); + + + it('should not automatically instantiate `$route` if disabled', function() { + module(function($routeProvider) { + $routeProvider.eagerInstantiationEnabled(false); + }); + + inject(function($location, $rootScope) { + $location.path('/foo'); + $rootScope.$digest(); + }); + + inject(function($route) { + expect($route.current).toBeUndefined(); + }); + }); +}); + + describe('$route', function() { var $httpBackend, element; @@ -12,8 +65,8 @@ describe('$route', function() { $httpBackend.when('GET', 'Chapter.html').respond('chapter'); $httpBackend.when('GET', 'test.html').respond('test'); $httpBackend.when('GET', 'foo.html').respond('foo'); - $httpBackend.when('GET', 'baz.html').respond('baz'); $httpBackend.when('GET', 'bar.html').respond('bar'); + $httpBackend.when('GET', 'baz.html').respond('baz'); $httpBackend.when('GET', 'http://example.com/trusted-template.html').respond('cross domain trusted template'); $httpBackend.when('GET', '404.html').respond('not found'); }; @@ -23,6 +76,7 @@ describe('$route', function() { dealoc(element); }); + it('should allow cancellation via $locationChangeStart via $routeChangeStart', function() { module(function($routeProvider) { $routeProvider.when('/Edit', { @@ -140,7 +194,7 @@ describe('$route', function() { $location.path('/NONE'); $rootScope.$digest(); expect(log).toEqual('before();after();'); - expect($route.current).toEqual(null); + expect($route.current).toEqual(undefined); }); }); @@ -198,7 +252,7 @@ describe('$route', function() { $location.path('/NONE'); $rootScope.$digest(); expect(log).toEqual('before();after();'); - expect($route.current).toEqual(null); + expect($route.current).toEqual(undefined); }); }); @@ -251,7 +305,7 @@ describe('$route', function() { $location.path('/BLANK'); $rootScope.$digest(); expect(log).toEqual('before();after();'); - expect($route.current).toEqual(null); + expect($route.current).toEqual(undefined); log = ''; $location.path('/Book2/Moby/one/two/Chapter/Intro').search('p=123'); @@ -263,7 +317,7 @@ describe('$route', function() { $location.path('/BOOK2/Moby/one/two/CHAPTER/Intro').search('p=123'); $rootScope.$digest(); expect(log).toEqual('before();after();'); - expect($route.current).toEqual(null); + expect($route.current).toEqual(undefined); }); }); @@ -303,7 +357,7 @@ describe('$route', function() { event.preventDefault(); }); - $rootScope.$on('$beforeRouteChange', function(event) { + $rootScope.$on('$routeChangeSuccess', function(event) { throw new Error('Should not get here'); }); @@ -350,7 +404,7 @@ describe('$route', function() { expect($route.current).toBeDefined(); })); - it("should use route params inherited from prototype chain", function() { + it('should use route params inherited from prototype chain', function() { function BaseRoute() {} BaseRoute.prototype.templateUrl = 'foo.html'; @@ -403,7 +457,7 @@ describe('$route', function() { $rootScope.$on('$routeChangeStart', callback); $location.path('/test'); $rootScope.$digest(); - callback.reset(); + callback.calls.reset(); $location.search({any: true}); $rootScope.$digest(); @@ -521,7 +575,7 @@ describe('$route', function() { expect($route.current.controller).toBe(NotFoundCtrl); expect(onChangeSpy).toHaveBeenCalled(); - onChangeSpy.reset(); + onChangeSpy.calls.reset(); $location.path('/foo'); $rootScope.$digest(); @@ -540,7 +594,7 @@ describe('$route', function() { inject(function($route, $location, $rootScope) { var currentRoute, nextRoute, - onChangeSpy = jasmine.createSpy('onChange').andCallFake(function(e, next) { + onChangeSpy = jasmine.createSpy('onChange').and.callFake(function(e, next) { currentRoute = $route.current; nextRoute = next; }); @@ -560,7 +614,7 @@ describe('$route', function() { expect(nextRoute.templateUrl).toBe('404.html'); expect($route.current.templateUrl).toBe('404.html'); expect(onChangeSpy).toHaveBeenCalled(); - onChangeSpy.reset(); + onChangeSpy.calls.reset(); // match regular route $location.path('/foo'); @@ -570,7 +624,7 @@ describe('$route', function() { expect(nextRoute.templateUrl).toBe('foo.html'); expect($route.current.templateUrl).toEqual('foo.html'); expect(onChangeSpy).toHaveBeenCalled(); - onChangeSpy.reset(); + onChangeSpy.calls.reset(); // match otherwise route again $location.path('/anotherUnknownRoute'); @@ -729,18 +783,27 @@ describe('$route', function() { }); inject(function($route, $location, $rootScope) { + var onError = jasmine.createSpy('onError'); + var onSuccess = jasmine.createSpy('onSuccess'); + + $rootScope.$on('$routeChangeError', onError); + $rootScope.$on('$routeChangeSuccess', onSuccess); + $location.path('/foo'); - expect(function() { - $rootScope.$digest(); - }).toThrowMinErr('$sce', 'insecurl', 'Blocked loading resource from url not allowed by ' + - '$sceDelegate policy. URL: http://example.com/foo.html'); + $rootScope.$digest(); + + expect(onSuccess).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalled(); + expect(onError.calls.mostRecent().args[3]).toEqualMinErr('$sce', 'insecurl', + 'Blocked loading resource from url not allowed by $sceDelegate policy. ' + + 'URL: http://example.com/foo.html'); }); }); it('should load cross domain templates that are trusted', function() { module(function($routeProvider, $sceDelegateProvider) { $routeProvider.when('/foo', { templateUrl: 'http://example.com/foo.html' }); - $sceDelegateProvider.resourceUrlWhitelist([/^http:\/\/example\.com\/foo\.html$/]); + $sceDelegateProvider.trustedResourceUrlList([/^http:\/\/example\.com\/foo\.html$/]); }); inject(function($route, $location, $rootScope) { @@ -829,7 +892,8 @@ describe('$route', function() { $rootScope.$digest(); $httpBackend.flush(); - expect($exceptionHandler.errors.pop().message).toContain("[$compile:tpload] Failed to load template: r1.html"); + expect($exceptionHandler.errors.pop()). + toEqualMinErr('$templateRequest', 'tpload', 'Failed to load template: r1.html'); $httpBackend.expectGET('r2.html').respond(''); $location.path('/r2'); @@ -850,8 +914,7 @@ describe('$route', function() { it('should catch local factory errors', function() { var myError = new Error('MyError'); - module(function($routeProvider, $exceptionHandlerProvider) { - $exceptionHandlerProvider.mode('log'); + module(function($routeProvider) { $routeProvider.when('/locals', { resolve: { a: function($q) { @@ -861,10 +924,14 @@ describe('$route', function() { }); }); - inject(function($location, $route, $rootScope, $exceptionHandler) { + inject(function($location, $route, $rootScope) { + spyOn($rootScope, '$broadcast').and.callThrough(); + $location.path('/locals'); $rootScope.$digest(); - expect($exceptionHandler.errors).toEqual([myError]); + + expect($rootScope.$broadcast).toHaveBeenCalledWith( + '$routeChangeError', jasmine.any(Object), undefined, myError); }); }); }); @@ -900,348 +967,1325 @@ describe('$route', function() { }); - describe('redirection', function() { - it('should support redirection via redirectTo property by updating $location', function() { + it('should not get affected by modifying the route definition object after route registration', + function() { module(function($routeProvider) { - $routeProvider.when('/', {redirectTo: '/foo'}); - $routeProvider.when('/foo', {templateUrl: 'foo.html'}); - $routeProvider.when('/bar', {templateUrl: 'bar.html'}); - $routeProvider.when('/baz', {redirectTo: '/bar'}); - $routeProvider.otherwise({templateUrl: '404.html'}); - }); - - inject(function($route, $location, $rootScope) { - var onChangeSpy = jasmine.createSpy('onChange'); + var rdo = {}; - $rootScope.$on('$routeChangeStart', onChangeSpy); - expect($route.current).toBeUndefined(); - expect(onChangeSpy).not.toHaveBeenCalled(); + rdo.templateUrl = 'foo.html'; + $routeProvider.when('/foo', rdo); - $location.path('/'); - $rootScope.$digest(); - expect($location.path()).toBe('/foo'); - expect($route.current.templateUrl).toBe('foo.html'); - expect(onChangeSpy.callCount).toBe(2); + rdo.templateUrl = 'bar.html'; + $routeProvider.when('/bar', rdo); + }); - onChangeSpy.reset(); - $location.path('/baz'); + inject(function($location, $rootScope, $route) { + $location.path('/bar'); $rootScope.$digest(); expect($location.path()).toBe('/bar'); expect($route.current.templateUrl).toBe('bar.html'); - expect(onChangeSpy.callCount).toBe(2); + + $location.path('/foo'); + $rootScope.$digest(); + expect($location.path()).toBe('/foo'); + expect($route.current.templateUrl).toBe('foo.html'); }); - }); + } + ); - it('should interpolate route vars in the redirected path from original path', function() { - module(function($routeProvider) { - $routeProvider.when('/foo/:id/foo/:subid/:extraId', {redirectTo: '/bar/:id/:subid/23'}); - $routeProvider.when('/bar/:id/:subid/:subsubid', {templateUrl: 'bar.html'}); - $routeProvider.when('/baz/:id/:path*', {redirectTo: '/path/:path/:id'}); - $routeProvider.when('/path/:path*/:id', {templateUrl: 'foo.html'}); - }); + it('should use the property values of the passed in route definition object directly', + function() { + var $routeProvider; - inject(function($route, $location, $rootScope) { - $location.path('/foo/id1/foo/subid3/gah'); - $rootScope.$digest(); + module(function(_$routeProvider_) { + $routeProvider = _$routeProvider_; + }); - expect($location.path()).toEqual('/bar/id1/subid3/23'); - expect($location.search()).toEqual({extraId: 'gah'}); - expect($route.current.templateUrl).toEqual('bar.html'); + inject(function($location, $rootScope, $route, $sce) { + var sceWrappedUrl = $sce.trustAsResourceUrl('foo.html'); + $routeProvider.when('/foo', {templateUrl: sceWrappedUrl}); - $location.path('/baz/1/foovalue/barvalue'); + $location.path('/foo'); $rootScope.$digest(); - expect($location.path()).toEqual('/path/foovalue/barvalue/1'); - expect($route.current.templateUrl).toEqual('foo.html'); + expect($location.path()).toBe('/foo'); + expect($route.current.templateUrl).toBe(sceWrappedUrl); }); - }); + } + ); - it('should interpolate route vars in the redirected path from original search', function() { - module(function($routeProvider) { - $routeProvider.when('/bar/:id/:subid/:subsubid', {templateUrl: 'bar.html'}); - $routeProvider.when('/foo/:id/:extra', {redirectTo: '/bar/:id/:subid/99'}); - }); + it('should support custom `$sce` implementations', function() { + function MySafeResourceUrl(val) { + var self = this; + this._val = val; + this.getVal = function() { + return (this !== self) ? null : this._val; + }; + } - inject(function($route, $location, $rootScope) { - $location.path('/foo/id3/eId').search('subid=sid1&appended=true'); - $rootScope.$digest(); + var $routeProvider; + + module(function($provide, _$routeProvider_) { + $routeProvider = _$routeProvider_; - expect($location.path()).toEqual('/bar/id3/sid1/99'); - expect($location.search()).toEqual({appended: 'true', extra: 'eId'}); - expect($route.current.templateUrl).toEqual('bar.html'); + $provide.decorator('$sce', function($delegate) { + function getVal(v) { return v.getVal ? v.getVal() : v; } + $delegate.trustAsResourceUrl = function(url) { return new MySafeResourceUrl(url); }; + $delegate.getTrustedResourceUrl = function(v) { return getVal(v); }; + $delegate.valueOf = function(v) { return getVal(v); }; + return $delegate; }); }); + inject(function($location, $rootScope, $route, $sce) { + $routeProvider.when('/foo', {templateUrl: $sce.trustAsResourceUrl('foo.html')}); - it('should properly interpolate optional and eager route vars ' + - 'when redirecting from path with trailing slash', function() { - module(function($routeProvider) { - $routeProvider.when('/foo/:id?/:subid?', {templateUrl: 'foo.html'}); - $routeProvider.when('/bar/:id*/:subid', {templateUrl: 'bar.html'}); - }); + $location.path('/foo'); + $rootScope.$digest(); + expect($location.path()).toBe('/foo'); + expect($sce.valueOf($route.current.templateUrl)).toBe('foo.html'); + }); + }); - inject(function($location, $rootScope, $route) { - $location.path('/foo/id1/subid2/'); - $rootScope.$digest(); - expect($location.path()).toEqual('/foo/id1/subid2'); - expect($route.current.templateUrl).toEqual('foo.html'); + describe('redirection', function() { + describe('via `redirectTo`', function() { + it('should support redirection via redirectTo property by updating $location', function() { + module(function($routeProvider) { + $routeProvider.when('/', {redirectTo: '/foo'}); + $routeProvider.when('/foo', {templateUrl: 'foo.html'}); + $routeProvider.when('/bar', {templateUrl: 'bar.html'}); + $routeProvider.when('/baz', {redirectTo: '/bar'}); + $routeProvider.otherwise({templateUrl: '404.html'}); + }); - $location.path('/bar/id1/extra/subid2/'); - $rootScope.$digest(); + inject(function($route, $location, $rootScope) { + var onChangeSpy = jasmine.createSpy('onChange'); - expect($location.path()).toEqual('/bar/id1/extra/subid2'); - expect($route.current.templateUrl).toEqual('bar.html'); + $rootScope.$on('$routeChangeStart', onChangeSpy); + expect($route.current).toBeUndefined(); + expect(onChangeSpy).not.toHaveBeenCalled(); + + $location.path('/'); + $rootScope.$digest(); + expect($location.path()).toBe('/foo'); + expect($route.current.templateUrl).toBe('foo.html'); + expect(onChangeSpy).toHaveBeenCalledTimes(2); + + onChangeSpy.calls.reset(); + $location.path('/baz'); + $rootScope.$digest(); + expect($location.path()).toBe('/bar'); + expect($route.current.templateUrl).toBe('bar.html'); + expect(onChangeSpy).toHaveBeenCalledTimes(2); + }); }); - }); - it('should allow custom redirectTo function to be used', function() { - function customRedirectFn(routePathParams, path, search) { - expect(routePathParams).toEqual({id: 'id3'}); - expect(path).toEqual('/foo/id3'); - expect(search).toEqual({ subid: 'sid1', appended: 'true' }); - return '/custom'; - } + it('should interpolate route vars in the redirected path from original path', function() { + module(function($routeProvider) { + $routeProvider.when('/foo/:id/foo/:subid/:extraId', {redirectTo: '/bar/:id/:subid/23'}); + $routeProvider.when('/bar/:id/:subid/:subsubid', {templateUrl: 'bar.html'}); + $routeProvider.when('/baz/:id/:path*', {redirectTo: '/path/:path/:id'}); + $routeProvider.when('/path/:path*/:id', {templateUrl: 'foo.html'}); + }); - module(function($routeProvider) { - $routeProvider.when('/bar/:id/:subid/:subsubid', {templateUrl: 'bar.html'}); - $routeProvider.when('/foo/:id', {redirectTo: customRedirectFn}); - }); + inject(function($route, $location, $rootScope) { + $location.path('/foo/id1/foo/subid3/gah'); + $rootScope.$digest(); - inject(function($route, $location, $rootScope) { - $location.path('/foo/id3').search('subid=sid1&appended=true'); - $rootScope.$digest(); + expect($location.path()).toEqual('/bar/id1/subid3/23'); + expect($location.search()).toEqual({extraId: 'gah'}); + expect($route.current.templateUrl).toEqual('bar.html'); - expect($location.path()).toEqual('/custom'); + $location.path('/baz/1/foovalue/barvalue'); + $rootScope.$digest(); + expect($location.path()).toEqual('/path/foovalue/barvalue/1'); + expect($route.current.templateUrl).toEqual('foo.html'); + }); }); - }); - it('should replace the url when redirecting', function() { - module(function($routeProvider) { - $routeProvider.when('/bar/:id', {templateUrl: 'bar.html'}); - $routeProvider.when('/foo/:id/:extra', {redirectTo: '/bar/:id'}); - }); - inject(function($browser, $route, $location, $rootScope) { - var $browserUrl = spyOnlyCallsWithArgs($browser, 'url').andCallThrough(); + it('should interpolate route vars in the redirected path from original search', function() { + module(function($routeProvider) { + $routeProvider.when('/bar/:id/:subid/:subsubid', {templateUrl: 'bar.html'}); + $routeProvider.when('/foo/:id/:extra', {redirectTo: '/bar/:id/:subid/99'}); + }); - $location.path('/foo/id3/eId'); - $rootScope.$digest(); + inject(function($route, $location, $rootScope) { + $location.path('/foo/id3/eId').search('subid=sid1&appended=true'); + $rootScope.$digest(); - expect($location.path()).toEqual('/bar/id3'); - expect($browserUrl.mostRecentCall.args) - .toEqual(['http://server/#/bar/id3?extra=eId', true, null]); + expect($location.path()).toEqual('/bar/id3/sid1/99'); + expect($location.search()).toEqual({appended: 'true', extra: 'eId'}); + expect($route.current.templateUrl).toEqual('bar.html'); + }); }); - }); - }); - describe('reloadOnSearch', function() { - it('should reload a route when reloadOnSearch is enabled and .search() changes', function() { - var reloaded = jasmine.createSpy('route reload'); - - module(function($routeProvider) { - $routeProvider.when('/foo', {controller: angular.noop}); - }); + it('should properly process route params which are both eager and optional', function() { + module(function($routeProvider) { + $routeProvider.when('/foo/:param1*?/:param2', {templateUrl: 'foo.html'}); + }); - inject(function($route, $location, $rootScope, $routeParams) { - $rootScope.$on('$routeChangeStart', reloaded); - $location.path('/foo'); - $rootScope.$digest(); - expect(reloaded).toHaveBeenCalled(); - expect($routeParams).toEqual({}); - reloaded.reset(); + inject(function($location, $rootScope, $route) { + $location.path('/foo/bar1/bar2/bar3/baz'); + $rootScope.$digest(); - // trigger reload - $location.search({foo: 'bar'}); - $rootScope.$digest(); - expect(reloaded).toHaveBeenCalled(); - expect($routeParams).toEqual({foo:'bar'}); - }); - }); + expect($location.path()).toEqual('/foo/bar1/bar2/bar3/baz'); + expect($route.current.params.param1).toEqual('bar1/bar2/bar3'); + expect($route.current.params.param2).toEqual('baz'); + expect($route.current.templateUrl).toEqual('foo.html'); + $location.path('/foo/baz'); + $rootScope.$digest(); - it('should not reload a route when reloadOnSearch is disabled and only .search() changes', function() { - var routeChange = jasmine.createSpy('route change'), - routeUpdate = jasmine.createSpy('route update'); + expect($location.path()).toEqual('/foo/baz'); + expect($route.current.params.param1).toEqual(undefined); + expect($route.current.params.param2).toEqual('baz'); + expect($route.current.templateUrl).toEqual('foo.html'); - module(function($routeProvider) { - $routeProvider.when('/foo', {controller: angular.noop, reloadOnSearch: false}); + }); }); - inject(function($route, $location, $rootScope) { - $rootScope.$on('$routeChangeStart', routeChange); - $rootScope.$on('$routeChangeSuccess', routeChange); - $rootScope.$on('$routeUpdate', routeUpdate); - - expect(routeChange).not.toHaveBeenCalled(); - $location.path('/foo'); - $rootScope.$digest(); - expect(routeChange).toHaveBeenCalled(); - expect(routeChange.callCount).toBe(2); - expect(routeUpdate).not.toHaveBeenCalled(); - routeChange.reset(); + it('should properly interpolate optional and eager route vars ' + + 'when redirecting from path with trailing slash', function() { + module(function($routeProvider) { + $routeProvider.when('/foo/:id?/:subid?', {templateUrl: 'foo.html'}); + $routeProvider.when('/bar/:id*/:subid', {templateUrl: 'bar.html'}); + }); - // don't trigger reload - $location.search({foo: 'bar'}); - $rootScope.$digest(); - expect(routeChange).not.toHaveBeenCalled(); - expect(routeUpdate).toHaveBeenCalled(); - }); - }); + inject(function($location, $rootScope, $route) { + $location.path('/foo/id1/subid2/'); + $rootScope.$digest(); + expect($location.path()).toEqual('/foo/id1/subid2'); + expect($route.current.templateUrl).toEqual('foo.html'); - it('should reload reloadOnSearch route when url differs only in route path param', function() { - var routeChange = jasmine.createSpy('route change'); + $location.path('/bar/id1/extra/subid2/'); + $rootScope.$digest(); - module(function($routeProvider) { - $routeProvider.when('/foo/:fooId', {controller: angular.noop, reloadOnSearch: false}); + expect($location.path()).toEqual('/bar/id1/extra/subid2'); + expect($route.current.templateUrl).toEqual('bar.html'); + }); }); - inject(function($route, $location, $rootScope) { - $rootScope.$on('$routeChangeStart', routeChange); - $rootScope.$on('$routeChangeSuccess', routeChange); - expect(routeChange).not.toHaveBeenCalled(); + it('should allow custom redirectTo function to be used', function() { + function customRedirectFn(routePathParams, path, search) { + expect(routePathParams).toEqual({id: 'id3'}); + expect(path).toEqual('/foo/id3'); + expect(search).toEqual({subid: 'sid1', appended: 'true'}); + return '/custom'; + } - $location.path('/foo/aaa'); - $rootScope.$digest(); - expect(routeChange).toHaveBeenCalled(); - expect(routeChange.callCount).toBe(2); - routeChange.reset(); + module(function($routeProvider) { + $routeProvider.when('/foo/:id', {redirectTo: customRedirectFn}); + }); - $location.path('/foo/bbb'); - $rootScope.$digest(); - expect(routeChange).toHaveBeenCalled(); - expect(routeChange.callCount).toBe(2); - routeChange.reset(); + inject(function($route, $location, $rootScope) { + $location.path('/foo/id3').search('subid=sid1&appended=true'); + $rootScope.$digest(); - $location.search({foo: 'bar'}); - $rootScope.$digest(); - expect(routeChange).not.toHaveBeenCalled(); + expect($location.path()).toEqual('/custom'); + }); }); - }); - it('should update params when reloadOnSearch is disabled and .search() changes', function() { - var routeParamsWatcher = jasmine.createSpy('routeParamsWatcher'); + it('should broadcast `$routeChangeError` when redirectTo throws', function() { + var error = new Error('Test'); - module(function($routeProvider) { - $routeProvider.when('/foo', {controller: angular.noop}); - $routeProvider.when('/bar/:barId', {controller: angular.noop, reloadOnSearch: false}); - }); + module(function($routeProvider) { + $routeProvider.when('/foo', {redirectTo: function() { throw error; }}); + }); - inject(function($route, $location, $rootScope, $routeParams) { - $rootScope.$watch(function() { - return $routeParams; - }, function(value) { - routeParamsWatcher(value); - }, true); + inject(function($exceptionHandler, $location, $rootScope, $route) { + spyOn($rootScope, '$broadcast').and.callThrough(); - expect(routeParamsWatcher).not.toHaveBeenCalled(); + $location.path('/foo'); + $rootScope.$digest(); - $location.path('/foo'); - $rootScope.$digest(); - expect(routeParamsWatcher).toHaveBeenCalledWith({}); - routeParamsWatcher.reset(); + var lastCallArgs = $rootScope.$broadcast.calls.mostRecent().args; + expect(lastCallArgs[0]).toBe('$routeChangeError'); + expect(lastCallArgs[3]).toBe(error); + }); + }); - // trigger reload - $location.search({foo: 'bar'}); - $rootScope.$digest(); - expect(routeParamsWatcher).toHaveBeenCalledWith({foo: 'bar'}); - routeParamsWatcher.reset(); - $location.path('/bar/123').search({}); - $rootScope.$digest(); - expect(routeParamsWatcher).toHaveBeenCalledWith({barId: '123'}); - routeParamsWatcher.reset(); + it('should replace the url when redirecting', function() { + module(function($routeProvider) { + $routeProvider.when('/bar/:id', {templateUrl: 'bar.html'}); + $routeProvider.when('/foo/:id/:extra', {redirectTo: '/bar/:id'}); + }); + inject(function($browser, $route, $location, $rootScope) { + var $browserUrl = spyOnlyCallsWithArgs($browser, 'url').and.callThrough(); - // don't trigger reload - $location.search({foo: 'bar'}); - $rootScope.$digest(); - expect(routeParamsWatcher).toHaveBeenCalledWith({barId: '123', foo: 'bar'}); + $location.path('/foo/id3/eId'); + $rootScope.$digest(); + + expect($location.path()).toEqual('/bar/id3'); + expect($browserUrl.calls.mostRecent().args) + .toEqual(['http://server/#!/bar/id3?extra=eId', true, null]); + }); }); - }); - it('should allow using a function as a template', function() { - var customTemplateWatcher = jasmine.createSpy('customTemplateWatcher'); + it('should not process route bits', function() { + var firstController = jasmine.createSpy('first controller spy'); + var firstTemplate = jasmine.createSpy('first template spy').and.returnValue('redirected view'); + var firstResolve = jasmine.createSpy('first resolve spy'); + var secondController = jasmine.createSpy('second controller spy'); + var secondTemplate = jasmine.createSpy('second template spy').and.returnValue('redirected view'); + var secondResolve = jasmine.createSpy('second resolve spy'); + module(function($routeProvider) { + $routeProvider.when('/redirect', { + template: firstTemplate, + redirectTo: '/redirected', + resolve: { value: firstResolve }, + controller: firstController + }); + $routeProvider.when('/redirected', { + template: secondTemplate, + resolve: { value: secondResolve }, + controller: secondController + }); + }); + inject(function($route, $location, $rootScope, $compile) { + var element = $compile('
          ')($rootScope); + $location.path('/redirect'); + $rootScope.$digest(); - function customTemplateFn(routePathParams) { - customTemplateWatcher(routePathParams); - expect(routePathParams).toEqual({id: 'id3'}); - return '

          ' + routePathParams.id + '

          '; - } + expect(firstController).not.toHaveBeenCalled(); + expect(firstTemplate).not.toHaveBeenCalled(); + expect(firstResolve).not.toHaveBeenCalled(); - module(function($routeProvider) { - $routeProvider.when('/bar/:id/:subid/:subsubid', {templateUrl: 'bar.html'}); - $routeProvider.when('/foo/:id', {template: customTemplateFn}); + expect(secondController).toHaveBeenCalled(); + expect(secondTemplate).toHaveBeenCalled(); + expect(secondResolve).toHaveBeenCalled(); + + dealoc(element); + }); }); - inject(function($route, $location, $rootScope) { - $location.path('/foo/id3'); - $rootScope.$digest(); - expect(customTemplateWatcher).toHaveBeenCalledWith({id: 'id3'}); + it('should not redirect transition if `redirectTo` returns `undefined`', function() { + var controller = jasmine.createSpy('first controller spy'); + var templateFn = jasmine.createSpy('first template spy').and.returnValue('redirected view'); + module(function($routeProvider) { + $routeProvider.when('/redirect/to/undefined', { + template: templateFn, + redirectTo: function() {}, + controller: controller + }); + }); + inject(function($route, $location, $rootScope, $compile) { + var element = $compile('
          ')($rootScope); + $location.path('/redirect/to/undefined'); + $rootScope.$digest(); + expect(controller).toHaveBeenCalled(); + expect(templateFn).toHaveBeenCalled(); + expect($location.path()).toEqual('/redirect/to/undefined'); + dealoc(element); + }); }); }); + describe('via `resolveRedirectTo`', function() { + var $compile; + var $location; + var $rootScope; + var $route; - it('should allow using a function as a templateUrl', function() { - var customTemplateUrlWatcher = jasmine.createSpy('customTemplateUrlWatcher'); + beforeEach(module(function() { + return function(_$compile_, _$location_, _$rootScope_, _$route_) { + $compile = _$compile_; + $location = _$location_; + $rootScope = _$rootScope_; + $route = _$route_; + }; + })); - function customTemplateUrlFn(routePathParams) { - customTemplateUrlWatcher(routePathParams); - expect(routePathParams).toEqual({id: 'id3'}); - return 'foo.html'; - } - module(function($routeProvider) { - $routeProvider.when('/bar/:id/:subid/:subsubid', {templateUrl: 'bar.html'}); - $routeProvider.when('/foo/:id', {templateUrl: customTemplateUrlFn}); - }); + it('should be ignored if `redirectTo` is also present', function() { + var newUrl; + var getNewUrl = function() { return newUrl; }; - inject(function($route, $location, $rootScope) { - $location.path('/foo/id3'); - $rootScope.$digest(); + var resolveRedirectToSpy = jasmine.createSpy('resolveRedirectTo').and.returnValue('/bar'); + var redirectToSpy = jasmine.createSpy('redirectTo').and.callFake(getNewUrl); + var templateSpy = jasmine.createSpy('template').and.returnValue('Foo'); - expect(customTemplateUrlWatcher).toHaveBeenCalledWith({id: 'id3'}); - expect($route.current.loadedTemplateUrl).toEqual('foo.html'); + module(function($routeProvider) { + $routeProvider. + when('/foo', { + resolveRedirectTo: resolveRedirectToSpy, + redirectTo: redirectToSpy, + template: templateSpy + }). + when('/bar', {template: 'Bar'}). + when('/baz', {template: 'Baz'}); + }); + + inject(function() { + newUrl = '/baz'; + $location.path('/foo'); + $rootScope.$digest(); + + expect($location.path()).toBe('/baz'); + expect($route.current.template).toBe('Baz'); + expect(resolveRedirectToSpy).not.toHaveBeenCalled(); + expect(redirectToSpy).toHaveBeenCalled(); + expect(templateSpy).not.toHaveBeenCalled(); + + redirectToSpy.calls.reset(); + + newUrl = undefined; + $location.path('/foo'); + $rootScope.$digest(); + + expect($location.path()).toBe('/foo'); + expect($route.current.template).toBe(templateSpy); + expect(resolveRedirectToSpy).not.toHaveBeenCalled(); + expect(redirectToSpy).toHaveBeenCalled(); + expect(templateSpy).toHaveBeenCalled(); + }); + }); + + + it('should redirect to the returned url', function() { + module(function($routeProvider) { + $routeProvider. + when('/foo', {resolveRedirectTo: function() { return '/bar?baz=qux'; }}). + when('/bar', {template: 'Bar'}); + }); + + inject(function() { + $location.path('/foo'); + $rootScope.$digest(); + + expect($location.path()).toBe('/bar'); + expect($location.search()).toEqual({baz: 'qux'}); + expect($route.current.template).toBe('Bar'); + }); + }); + + + it('should support returning a promise', function() { + module(function($routeProvider) { + $routeProvider. + when('/foo', {resolveRedirectTo: function($q) { return $q.resolve('/bar'); }}). + when('/bar', {template: 'Bar'}); + }); + + inject(function() { + $location.path('/foo'); + $rootScope.$digest(); + + expect($location.path()).toBe('/bar'); + expect($route.current.template).toBe('Bar'); + }); + }); + + + it('should support dependency injection', function() { + module(function($provide, $routeProvider) { + $provide.value('nextRoute', '/bar'); + + $routeProvider. + when('/foo', { + resolveRedirectTo: function(nextRoute) { + return nextRoute; + } + }); + }); + + inject(function() { + $location.path('/foo'); + $rootScope.$digest(); + + expect($location.path()).toBe('/bar'); + }); + }); + + + it('should have access to the current routeParams via `$route.current.params`', function() { + module(function($routeProvider) { + $routeProvider. + when('/foo/:bar/baz/:qux', { + resolveRedirectTo: function($route) { + expect($route.current.params).toEqual(jasmine.objectContaining({ + bar: '1', + qux: '2' + })); + + return '/passed'; + } + }); + }); + + inject(function() { + $location.path('/foo/1/baz/2').search({bar: 'qux'}); + $rootScope.$digest(); + + expect($location.path()).toBe('/passed'); + }); + }); + + + it('should not process route bits until the promise is resolved', function() { + var spies = createSpies(); + var called = false; + var deferred; + + module(function($routeProvider) { + setupRoutes($routeProvider, spies, function($q) { + called = true; + deferred = $q.defer(); + return deferred.promise; + }); + }); + + inject(function() { + var element = $compile('
          ')($rootScope); + + $location.path('/foo'); + $rootScope.$digest(); + + expect($location.path()).toBe('/foo'); + expect(called).toBe(true); + expect(spies.fooResolveSpy).not.toHaveBeenCalled(); + expect(spies.fooTemplateSpy).not.toHaveBeenCalled(); + expect(spies.fooControllerSpy).not.toHaveBeenCalled(); + expect(spies.barResolveSpy).not.toHaveBeenCalled(); + expect(spies.barTemplateSpy).not.toHaveBeenCalled(); + expect(spies.barControllerSpy).not.toHaveBeenCalled(); + + deferred.resolve('/bar'); + $rootScope.$digest(); + expect($location.path()).toBe('/bar'); + expect(spies.fooResolveSpy).not.toHaveBeenCalled(); + expect(spies.fooTemplateSpy).not.toHaveBeenCalled(); + expect(spies.fooControllerSpy).not.toHaveBeenCalled(); + expect(spies.barResolveSpy).toHaveBeenCalled(); + expect(spies.barTemplateSpy).toHaveBeenCalled(); + expect(spies.barControllerSpy).toHaveBeenCalled(); + + dealoc(element); + }); + }); + + + it('should not redirect if `undefined` is returned', function() { + var spies = createSpies(); + var called = false; + + module(function($routeProvider) { + setupRoutes($routeProvider, spies, function() { + called = true; + return undefined; + }); + }); + + inject(function() { + var element = $compile('
          ')($rootScope); + + $location.path('/foo'); + $rootScope.$digest(); + + expect($location.path()).toBe('/foo'); + expect(called).toBe(true); + expect(spies.fooResolveSpy).toHaveBeenCalled(); + expect(spies.fooTemplateSpy).toHaveBeenCalled(); + expect(spies.fooControllerSpy).toHaveBeenCalled(); + expect(spies.barResolveSpy).not.toHaveBeenCalled(); + expect(spies.barTemplateSpy).not.toHaveBeenCalled(); + expect(spies.barControllerSpy).not.toHaveBeenCalled(); + + dealoc(element); + }); + }); + + + it('should not redirect if the returned promise resolves to `undefined`', function() { + var spies = createSpies(); + var called = false; + + module(function($routeProvider) { + setupRoutes($routeProvider, spies, function($q) { + called = true; + return $q.resolve(undefined); + }); + }); + + inject(function() { + var element = $compile('
          ')($rootScope); + + $location.path('/foo'); + $rootScope.$digest(); + + expect($location.path()).toBe('/foo'); + expect(called).toBe(true); + expect(spies.fooResolveSpy).toHaveBeenCalled(); + expect(spies.fooTemplateSpy).toHaveBeenCalled(); + expect(spies.fooControllerSpy).toHaveBeenCalled(); + expect(spies.barResolveSpy).not.toHaveBeenCalled(); + expect(spies.barTemplateSpy).not.toHaveBeenCalled(); + expect(spies.barControllerSpy).not.toHaveBeenCalled(); + + dealoc(element); + }); + }); + + + it('should not redirect if the returned promise gets rejected', function() { + var spies = createSpies(); + var called = false; + + module(function($routeProvider) { + setupRoutes($routeProvider, spies, function($q) { + called = true; + return $q.reject(''); + }); + }); + + inject(function() { + spyOn($rootScope, '$broadcast').and.callThrough(); + + var element = $compile('
          ')($rootScope); + + $location.path('/foo'); + $rootScope.$digest(); + + expect($location.path()).toBe('/foo'); + expect(called).toBe(true); + expect(spies.fooResolveSpy).not.toHaveBeenCalled(); + expect(spies.fooTemplateSpy).not.toHaveBeenCalled(); + expect(spies.fooControllerSpy).not.toHaveBeenCalled(); + expect(spies.barResolveSpy).not.toHaveBeenCalled(); + expect(spies.barTemplateSpy).not.toHaveBeenCalled(); + expect(spies.barControllerSpy).not.toHaveBeenCalled(); + + var lastCallArgs = $rootScope.$broadcast.calls.mostRecent().args; + expect(lastCallArgs[0]).toBe('$routeChangeError'); + + dealoc(element); + }); + }); + + + it('should ignore previous redirection if newer transition happened', function() { + var spies = createSpies(); + var called = false; + var deferred; + + module(function($routeProvider) { + setupRoutes($routeProvider, spies, function($q) { + called = true; + deferred = $q.defer(); + return deferred.promise; + }); + }); + + inject(function() { + spyOn($location, 'url').and.callThrough(); + + var element = $compile('
          ')($rootScope); + + $location.path('/foo'); + $rootScope.$digest(); + + expect($location.path()).toBe('/foo'); + expect(called).toBe(true); + expect(spies.fooResolveSpy).not.toHaveBeenCalled(); + expect(spies.fooTemplateSpy).not.toHaveBeenCalled(); + expect(spies.fooControllerSpy).not.toHaveBeenCalled(); + expect(spies.barResolveSpy).not.toHaveBeenCalled(); + expect(spies.barTemplateSpy).not.toHaveBeenCalled(); + expect(spies.barControllerSpy).not.toHaveBeenCalled(); + expect(spies.bazResolveSpy).not.toHaveBeenCalled(); + expect(spies.bazTemplateSpy).not.toHaveBeenCalled(); + expect(spies.bazControllerSpy).not.toHaveBeenCalled(); + + $location.path('/baz'); + $rootScope.$digest(); + + expect($location.path()).toBe('/baz'); + expect(spies.fooResolveSpy).not.toHaveBeenCalled(); + expect(spies.fooTemplateSpy).not.toHaveBeenCalled(); + expect(spies.fooControllerSpy).not.toHaveBeenCalled(); + expect(spies.barResolveSpy).not.toHaveBeenCalled(); + expect(spies.barTemplateSpy).not.toHaveBeenCalled(); + expect(spies.barControllerSpy).not.toHaveBeenCalled(); + expect(spies.bazResolveSpy).toHaveBeenCalledOnce(); + expect(spies.bazTemplateSpy).toHaveBeenCalledOnce(); + expect(spies.bazControllerSpy).toHaveBeenCalledOnce(); + + deferred.resolve(); + $rootScope.$digest(); + + expect($location.path()).toBe('/baz'); + expect(spies.fooResolveSpy).not.toHaveBeenCalled(); + expect(spies.fooTemplateSpy).not.toHaveBeenCalled(); + expect(spies.fooControllerSpy).not.toHaveBeenCalled(); + expect(spies.barResolveSpy).not.toHaveBeenCalled(); + expect(spies.barTemplateSpy).not.toHaveBeenCalled(); + expect(spies.barControllerSpy).not.toHaveBeenCalled(); + expect(spies.bazResolveSpy).toHaveBeenCalledOnce(); + expect(spies.bazTemplateSpy).toHaveBeenCalledOnce(); + expect(spies.bazControllerSpy).toHaveBeenCalledOnce(); + + dealoc(element); + }); + }); + + + // Helpers + function createSpies() { + return { + fooResolveSpy: jasmine.createSpy('fooResolve'), + fooTemplateSpy: jasmine.createSpy('fooTemplate').and.returnValue('Foo'), + fooControllerSpy: jasmine.createSpy('fooController'), + barResolveSpy: jasmine.createSpy('barResolve'), + barTemplateSpy: jasmine.createSpy('barTemplate').and.returnValue('Bar'), + barControllerSpy: jasmine.createSpy('barController'), + bazResolveSpy: jasmine.createSpy('bazResolve'), + bazTemplateSpy: jasmine.createSpy('bazTemplate').and.returnValue('Baz'), + bazControllerSpy: jasmine.createSpy('bazController') + }; + } + + function setupRoutes(routeProvider, spies, resolveRedirectToFn) { + routeProvider. + when('/foo', { + resolveRedirectTo: resolveRedirectToFn, + resolve: {_: spies.fooResolveSpy}, + template: spies.fooTemplateSpy, + controller: spies.fooControllerSpy + }). + when('/bar', { + resolve: {_: spies.barResolveSpy}, + template: spies.barTemplateSpy, + controller: spies.barControllerSpy + }). + when('/baz', { + resolve: {_: spies.bazResolveSpy}, + template: spies.bazTemplateSpy, + controller: spies.bazControllerSpy + }); + } + }); + }); + + + describe('reloadOnUrl', function() { + it('should reload when `reloadOnUrl` is true and `.url()` changes', function() { + var routeChange = jasmine.createSpy('routeChange'); + + module(function($routeProvider) { + $routeProvider.when('/path/:param', {}); + }); + + inject(function($location, $rootScope, $routeParams) { + $rootScope.$on('$routeChangeStart', routeChange); + + // Initial load + $location.path('/path/foo'); + $rootScope.$digest(); + expect(routeChange).toHaveBeenCalledOnce(); + expect($routeParams).toEqual({param: 'foo'}); + + routeChange.calls.reset(); + + // Reload on `path` change + $location.path('/path/bar'); + $rootScope.$digest(); + expect(routeChange).toHaveBeenCalledOnce(); + expect($routeParams).toEqual({param: 'bar'}); + + routeChange.calls.reset(); + + // Reload on `search` change + $location.search('foo', 'bar'); + $rootScope.$digest(); + expect(routeChange).toHaveBeenCalledOnce(); + expect($routeParams).toEqual({param: 'bar', foo: 'bar'}); + + routeChange.calls.reset(); + + // Reload on `hash` change + $location.hash('baz'); + $rootScope.$digest(); + expect(routeChange).toHaveBeenCalledOnce(); + expect($routeParams).toEqual({param: 'bar', foo: 'bar'}); }); }); - describe('reload', function() { - it('should reload even if reloadOnSearch is false', function() { - var routeChangeSpy = jasmine.createSpy('route change'); + it('should reload when `reloadOnUrl` is false and URL maps to different route', + function() { + var routeChange = jasmine.createSpy('routeChange'); + var routeUpdate = jasmine.createSpy('routeUpdate'); module(function($routeProvider) { - $routeProvider.when('/bar/:barId', {controller: angular.noop, reloadOnSearch: false}); + $routeProvider. + when('/path/:param', {reloadOnUrl: false}). + otherwise({}); }); - inject(function($route, $location, $rootScope, $routeParams) { - $rootScope.$on('$routeChangeSuccess', routeChangeSpy); + inject(function($location, $rootScope, $routeParams) { + $rootScope.$on('$routeChangeStart', routeChange); + $rootScope.$on('$routeChangeSuccess', routeChange); + $rootScope.$on('$routeUpdate', routeUpdate); + + expect(routeChange).not.toHaveBeenCalled(); + + // Initial load + $location.path('/path/foo'); + $rootScope.$digest(); + expect(routeChange).toHaveBeenCalledTimes(2); + expect(routeUpdate).not.toHaveBeenCalled(); + expect($routeParams).toEqual({param: 'foo'}); + + routeChange.calls.reset(); + + // Route change + $location.path('/other/path/bar'); + $rootScope.$digest(); + expect(routeChange).toHaveBeenCalledTimes(2); + expect(routeUpdate).not.toHaveBeenCalled(); + expect($routeParams).toEqual({}); + }); + } + ); + + + it('should not reload when `reloadOnUrl` is false and URL maps to the same route', + function() { + var routeChange = jasmine.createSpy('routeChange'); + var routeUpdate = jasmine.createSpy('routeUpdate'); + + module(function($routeProvider) { + $routeProvider.when('/path/:param', {reloadOnUrl: false}); + }); + + inject(function($location, $rootScope, $routeParams) { + $rootScope.$on('$routeChangeStart', routeChange); + $rootScope.$on('$routeChangeSuccess', routeChange); + $rootScope.$on('$routeUpdate', routeUpdate); + + expect(routeChange).not.toHaveBeenCalled(); + + // Initial load + $location.path('/path/foo'); + $rootScope.$digest(); + expect(routeChange).toHaveBeenCalledTimes(2); + expect(routeUpdate).not.toHaveBeenCalled(); + expect($routeParams).toEqual({param: 'foo'}); + + routeChange.calls.reset(); + + // Route update (no reload) + $location.path('/path/bar').search('foo', 'bar').hash('baz'); + $rootScope.$digest(); + expect(routeChange).not.toHaveBeenCalled(); + expect(routeUpdate).toHaveBeenCalledOnce(); + expect($routeParams).toEqual({param: 'bar', foo: 'bar'}); + }); + } + ); + + + it('should update `$routeParams` even when not reloading a route', function() { + var routeChange = jasmine.createSpy('routeChange'); + + module(function($routeProvider) { + $routeProvider.when('/path/:param', {reloadOnUrl: false}); + }); + + inject(function($location, $rootScope, $routeParams) { + $rootScope.$on('$routeChangeStart', routeChange); + $rootScope.$on('$routeChangeSuccess', routeChange); + + expect(routeChange).not.toHaveBeenCalled(); + + // Initial load + $location.path('/path/foo'); + $rootScope.$digest(); + expect(routeChange).toHaveBeenCalledTimes(2); + expect($routeParams).toEqual({param: 'foo'}); + + routeChange.calls.reset(); + + // Route update (no reload) + $location.path('/path/bar'); + $rootScope.$digest(); + expect(routeChange).not.toHaveBeenCalled(); + expect($routeParams).toEqual({param: 'bar'}); + }); + }); + + + describe('with `$route.reload()`', function() { + var $location; + var $log; + var $rootScope; + var $route; + var routeChangeStart; + var routeChangeSuccess; + + beforeEach(module(function($routeProvider) { + $routeProvider.when('/path/:param', { + template: '', + reloadOnUrl: false, + controller: function Controller($log) { + $log.debug('initialized'); + } + }); + })); + + beforeEach(inject(function($compile, _$location_, _$log_, _$rootScope_, _$route_) { + $location = _$location_; + $log = _$log_; + $rootScope = _$rootScope_; + $route = _$route_; + + routeChangeStart = jasmine.createSpy('routeChangeStart'); + routeChangeSuccess = jasmine.createSpy('routeChangeSuccess'); + + $rootScope.$on('$routeChangeStart', routeChangeStart); + $rootScope.$on('$routeChangeSuccess', routeChangeSuccess); + + element = $compile('
          ')($rootScope); + })); + + + it('should reload the current route', function() { + $location.path('/path/foo'); + $rootScope.$digest(); + expect($location.path()).toBe('/path/foo'); + expect(routeChangeStart).toHaveBeenCalledOnce(); + expect(routeChangeSuccess).toHaveBeenCalledOnce(); + expect($log.debug.logs).toEqual([['initialized']]); + + routeChangeStart.calls.reset(); + routeChangeSuccess.calls.reset(); + $log.reset(); + + $route.reload(); + $rootScope.$digest(); + expect($location.path()).toBe('/path/foo'); + expect(routeChangeStart).toHaveBeenCalledOnce(); + expect(routeChangeSuccess).toHaveBeenCalledOnce(); + expect($log.debug.logs).toEqual([['initialized']]); - $location.path('/bar/123'); + $log.reset(); + }); + + + it('should support preventing a route reload', function() { + $location.path('/path/foo'); + $rootScope.$digest(); + expect($location.path()).toBe('/path/foo'); + expect(routeChangeStart).toHaveBeenCalledOnce(); + expect(routeChangeSuccess).toHaveBeenCalledOnce(); + expect($log.debug.logs).toEqual([['initialized']]); + + routeChangeStart.calls.reset(); + routeChangeSuccess.calls.reset(); + $log.reset(); + + routeChangeStart.and.callFake(function(evt) { evt.preventDefault(); }); + + $route.reload(); + $rootScope.$digest(); + expect($location.path()).toBe('/path/foo'); + expect(routeChangeStart).toHaveBeenCalledOnce(); + expect(routeChangeSuccess).not.toHaveBeenCalled(); + expect($log.debug.logs).toEqual([]); + }); + + + it('should reload the current route even if `reloadOnUrl` is disabled', + inject(function($routeParams) { + $location.path('/path/foo'); $rootScope.$digest(); - expect($routeParams).toEqual({barId:'123'}); - expect(routeChangeSpy).toHaveBeenCalledOnce(); - routeChangeSpy.reset(); + expect(routeChangeStart).toHaveBeenCalledOnce(); + expect(routeChangeSuccess).toHaveBeenCalledOnce(); + expect($log.debug.logs).toEqual([['initialized']]); + expect($routeParams).toEqual({param: 'foo'}); + + routeChangeStart.calls.reset(); + routeChangeSuccess.calls.reset(); + $log.reset(); - $location.path('/bar/123').search('a=b'); + $location.path('/path/bar'); $rootScope.$digest(); - expect($routeParams).toEqual({barId:'123', a:'b'}); - expect(routeChangeSpy).not.toHaveBeenCalled(); + expect(routeChangeStart).not.toHaveBeenCalled(); + expect(routeChangeSuccess).not.toHaveBeenCalled(); + expect($log.debug.logs).toEqual([]); + expect($routeParams).toEqual({param: 'bar'}); $route.reload(); $rootScope.$digest(); - expect($routeParams).toEqual({barId:'123', a:'b'}); - expect(routeChangeSpy).toHaveBeenCalledOnce(); + expect(routeChangeStart).toHaveBeenCalledOnce(); + expect(routeChangeSuccess).toHaveBeenCalledOnce(); + expect($log.debug.logs).toEqual([['initialized']]); + expect($routeParams).toEqual({param: 'bar'}); + + $log.reset(); + }) + ); + }); + }); + + describe('reloadOnSearch', function() { + it('should not have any effect if `reloadOnUrl` is false', function() { + var reloaded = jasmine.createSpy('route reload'); + + module(function($routeProvider) { + $routeProvider.when('/foo', { + reloadOnUrl: false, + reloadOnSearch: true + }); + }); + + inject(function($route, $location, $rootScope, $routeParams) { + $rootScope.$on('$routeChangeStart', reloaded); + + $location.path('/foo'); + $rootScope.$digest(); + expect(reloaded).toHaveBeenCalledOnce(); + expect($routeParams).toEqual({}); + + reloaded.calls.reset(); + + // trigger reload (via .search()) + $location.search({foo: 'bar'}); + $rootScope.$digest(); + expect(reloaded).not.toHaveBeenCalled(); + expect($routeParams).toEqual({foo: 'bar'}); + + // trigger reload (via .hash()) + $location.hash('baz'); + $rootScope.$digest(); + expect(reloaded).not.toHaveBeenCalled(); + expect($routeParams).toEqual({foo: 'bar'}); + }); + }); + + + it('should reload when `reloadOnSearch` is true and `.search()`/`.hash()` changes', + function() { + var reloaded = jasmine.createSpy('route reload'); + + module(function($routeProvider) { + $routeProvider.when('/foo', {controller: angular.noop}); + }); + + inject(function($route, $location, $rootScope, $routeParams) { + $rootScope.$on('$routeChangeStart', reloaded); + + $location.path('/foo'); + $rootScope.$digest(); + expect(reloaded).toHaveBeenCalledOnce(); + expect($routeParams).toEqual({}); + + reloaded.calls.reset(); + + // trigger reload (via .search()) + $location.search({foo: 'bar'}); + $rootScope.$digest(); + expect(reloaded).toHaveBeenCalledOnce(); + expect($routeParams).toEqual({foo: 'bar'}); + + reloaded.calls.reset(); + + // trigger reload (via .hash()) + $location.hash('baz'); + $rootScope.$digest(); + expect(reloaded).toHaveBeenCalledOnce(); + expect($routeParams).toEqual({foo: 'bar'}); + }); + } + ); + + + it('should not reload when `reloadOnSearch` is false and `.search()`/`.hash()` changes', + function() { + var routeChange = jasmine.createSpy('route change'), + routeUpdate = jasmine.createSpy('route update'); + + module(function($routeProvider) { + $routeProvider.when('/foo', {controller: angular.noop, reloadOnSearch: false}); + }); + + inject(function($route, $location, $rootScope) { + $rootScope.$on('$routeChangeStart', routeChange); + $rootScope.$on('$routeChangeSuccess', routeChange); + $rootScope.$on('$routeUpdate', routeUpdate); + + expect(routeChange).not.toHaveBeenCalled(); + + $location.path('/foo'); + $rootScope.$digest(); + expect(routeChange).toHaveBeenCalledTimes(2); + expect(routeUpdate).not.toHaveBeenCalled(); + + routeChange.calls.reset(); + + // don't trigger reload (via .search()) + $location.search({foo: 'bar'}); + $rootScope.$digest(); + expect(routeChange).not.toHaveBeenCalled(); + expect(routeUpdate).toHaveBeenCalledOnce(); + + routeUpdate.calls.reset(); + + // don't trigger reload (via .hash()) + $location.hash('baz'); + $rootScope.$digest(); + expect(routeChange).not.toHaveBeenCalled(); + expect(routeUpdate).toHaveBeenCalled(); + }); + } + ); + + + it('should reload when `reloadOnSearch` is false and url differs only in route path param', + function() { + var routeChange = jasmine.createSpy('route change'); + + module(function($routeProvider) { + $routeProvider.when('/foo/:fooId', {controller: angular.noop, reloadOnSearch: false}); + }); + + inject(function($route, $location, $rootScope) { + $rootScope.$on('$routeChangeStart', routeChange); + $rootScope.$on('$routeChangeSuccess', routeChange); + + expect(routeChange).not.toHaveBeenCalled(); + + $location.path('/foo/aaa'); + $rootScope.$digest(); + expect(routeChange).toHaveBeenCalledTimes(2); + routeChange.calls.reset(); + + $location.path('/foo/bbb'); + $rootScope.$digest(); + expect(routeChange).toHaveBeenCalledTimes(2); + routeChange.calls.reset(); + + $location.search({foo: 'bar'}).hash('baz'); + $rootScope.$digest(); + expect(routeChange).not.toHaveBeenCalled(); + }); + } + ); + + + it('should update params when `reloadOnSearch` is false and `.search()` changes', function() { + var routeParamsWatcher = jasmine.createSpy('routeParamsWatcher'); + + module(function($routeProvider) { + $routeProvider.when('/foo', {controller: angular.noop}); + $routeProvider.when('/bar/:barId', {controller: angular.noop, reloadOnSearch: false}); + }); + + inject(function($route, $location, $rootScope, $routeParams) { + $rootScope.$watch(function() { + return $routeParams; + }, function(value) { + routeParamsWatcher(value); + }, true); + + expect(routeParamsWatcher).not.toHaveBeenCalled(); + + $location.path('/foo'); + $rootScope.$digest(); + expect(routeParamsWatcher).toHaveBeenCalledWith({}); + routeParamsWatcher.calls.reset(); + + // trigger reload + $location.search({foo: 'bar'}); + $rootScope.$digest(); + expect(routeParamsWatcher).toHaveBeenCalledWith({foo: 'bar'}); + routeParamsWatcher.calls.reset(); + + $location.path('/bar/123').search({}); + $rootScope.$digest(); + expect(routeParamsWatcher).toHaveBeenCalledWith({barId: '123'}); + routeParamsWatcher.calls.reset(); + + // don't trigger reload + $location.search({foo: 'bar'}); + $rootScope.$digest(); + expect(routeParamsWatcher).toHaveBeenCalledWith({barId: '123', foo: 'bar'}); + }); + }); + + + it('should allow using a function as a template', function() { + var customTemplateWatcher = jasmine.createSpy('customTemplateWatcher'); + + function customTemplateFn(routePathParams) { + customTemplateWatcher(routePathParams); + expect(routePathParams).toEqual({id: 'id3'}); + return '

          ' + routePathParams.id + '

          '; + } + + module(function($routeProvider) { + $routeProvider.when('/bar/:id/:subid/:subsubid', {templateUrl: 'bar.html'}); + $routeProvider.when('/foo/:id', {template: customTemplateFn}); + }); + + inject(function($route, $location, $rootScope) { + $location.path('/foo/id3'); + $rootScope.$digest(); + + expect(customTemplateWatcher).toHaveBeenCalledWith({id: 'id3'}); + }); + }); + + + it('should allow using a function as a templateUrl', function() { + var customTemplateUrlWatcher = jasmine.createSpy('customTemplateUrlWatcher'); + + function customTemplateUrlFn(routePathParams) { + customTemplateUrlWatcher(routePathParams); + expect(routePathParams).toEqual({id: 'id3'}); + return 'foo.html'; + } + + module(function($routeProvider) { + $routeProvider.when('/bar/:id/:subid/:subsubid', {templateUrl: 'bar.html'}); + $routeProvider.when('/foo/:id', {templateUrl: customTemplateUrlFn}); + }); + + inject(function($route, $location, $rootScope) { + $location.path('/foo/id3'); + $rootScope.$digest(); + + expect(customTemplateUrlWatcher).toHaveBeenCalledWith({id: 'id3'}); + expect($route.current.loadedTemplateUrl).toEqual('foo.html'); + }); + }); + + + describe('with `$route.reload()`', function() { + var $location; + var $log; + var $rootScope; + var $route; + var routeChangeStartSpy; + var routeChangeSuccessSpy; + + beforeEach(module(function($routeProvider) { + $routeProvider.when('/bar/:barId', { + template: '', + controller: controller, + reloadOnSearch: false }); + + function controller($log) { + $log.debug('initialized'); + } + })); + beforeEach(inject(function($compile, _$location_, _$log_, _$rootScope_, _$route_) { + $location = _$location_; + $log = _$log_; + $rootScope = _$rootScope_; + $route = _$route_; + + routeChangeStartSpy = jasmine.createSpy('routeChangeStart'); + routeChangeSuccessSpy = jasmine.createSpy('routeChangeSuccess'); + + $rootScope.$on('$routeChangeStart', routeChangeStartSpy); + $rootScope.$on('$routeChangeSuccess', routeChangeSuccessSpy); + + element = $compile('
          ')($rootScope); + })); + + + it('should reload the current route', function() { + $location.path('/bar/123'); + $rootScope.$digest(); + expect($location.path()).toBe('/bar/123'); + expect(routeChangeStartSpy).toHaveBeenCalledOnce(); + expect(routeChangeSuccessSpy).toHaveBeenCalledOnce(); + expect($log.debug.logs).toEqual([['initialized']]); + + routeChangeStartSpy.calls.reset(); + routeChangeSuccessSpy.calls.reset(); + $log.reset(); + + $route.reload(); + $rootScope.$digest(); + expect($location.path()).toBe('/bar/123'); + expect(routeChangeStartSpy).toHaveBeenCalledOnce(); + expect(routeChangeSuccessSpy).toHaveBeenCalledOnce(); + expect($log.debug.logs).toEqual([['initialized']]); + + $log.reset(); }); + + + it('should support preventing a route reload', function() { + $location.path('/bar/123'); + $rootScope.$digest(); + expect($location.path()).toBe('/bar/123'); + expect(routeChangeStartSpy).toHaveBeenCalledOnce(); + expect(routeChangeSuccessSpy).toHaveBeenCalledOnce(); + expect($log.debug.logs).toEqual([['initialized']]); + + routeChangeStartSpy.calls.reset(); + routeChangeSuccessSpy.calls.reset(); + $log.reset(); + + routeChangeStartSpy.and.callFake(function(evt) { evt.preventDefault(); }); + + $route.reload(); + $rootScope.$digest(); + expect($location.path()).toBe('/bar/123'); + expect(routeChangeStartSpy).toHaveBeenCalledOnce(); + expect(routeChangeSuccessSpy).not.toHaveBeenCalled(); + expect($log.debug.logs).toEqual([]); + }); + + + it('should reload even if reloadOnSearch is false', inject(function($routeParams) { + $location.path('/bar/123'); + $rootScope.$digest(); + expect($routeParams).toEqual({barId: '123'}); + expect(routeChangeSuccessSpy).toHaveBeenCalledOnce(); + expect($log.debug.logs).toEqual([['initialized']]); + + routeChangeSuccessSpy.calls.reset(); + $log.reset(); + + $location.search('a=b'); + $rootScope.$digest(); + expect($routeParams).toEqual({barId: '123', a: 'b'}); + expect(routeChangeSuccessSpy).not.toHaveBeenCalled(); + expect($log.debug.logs).toEqual([]); + + routeChangeSuccessSpy.calls.reset(); + $log.reset(); + + $location.hash('c'); + $rootScope.$digest(); + expect($routeParams).toEqual({barId: '123', a: 'b'}); + expect(routeChangeSuccessSpy).not.toHaveBeenCalled(); + expect($log.debug.logs).toEqual([]); + + $route.reload(); + $rootScope.$digest(); + expect($routeParams).toEqual({barId: '123', a: 'b'}); + expect(routeChangeSuccessSpy).toHaveBeenCalledOnce(); + expect($log.debug.logs).toEqual([['initialized']]); + + $log.reset(); + })); }); }); @@ -1258,7 +2302,7 @@ describe('$route', function() { $location.path('/bar/1'); $rootScope.$digest(); - routeChangeSpy.reset(); + routeChangeSpy.calls.reset(); $route.updateParams({barId: '2'}); $rootScope.$digest(); @@ -1281,7 +2325,7 @@ describe('$route', function() { $location.path('/bar/1/2/3/4'); $rootScope.$digest(); - routeChangeSpy.reset(); + routeChangeSpy.calls.reset(); $route.updateParams({barId: '5', fooId: '6', spamId: '7', eggId: '8'}); $rootScope.$digest(); @@ -1304,7 +2348,7 @@ describe('$route', function() { $location.path('/bar/1/2/3/4'); $rootScope.$digest(); - routeChangeSpy.reset(); + routeChangeSpy.calls.reset(); $route.updateParams({barId: '5', fooId: '6'}); $rootScope.$digest(); @@ -1329,7 +2373,7 @@ describe('$route', function() { $location.path('/bar/1/2/3'); $location.search({initial: 'true'}); $rootScope.$digest(); - routeChangeSpy.reset(); + routeChangeSpy.calls.reset(); $route.updateParams({barId: '5', fooId: '6', eggId: '4'}); $rootScope.$digest(); @@ -1354,7 +2398,7 @@ describe('$route', function() { $location.path('/bar/1/2/3'); $location.search({initial: 'true'}); $rootScope.$digest(); - routeChangeSpy.reset(); + routeChangeSpy.calls.reset(); $route.updateParams({barId: '5', fooId: '6', eggId: '4'}); $rootScope.$digest(); @@ -1367,7 +2411,170 @@ describe('$route', function() { }); it('should complain if called without an existing route', inject(function($route) { - expect($route.updateParams).toThrowMinErr('ngRoute', 'norout'); + expect(function() { $route.updateParams(); }).toThrowMinErr('ngRoute', 'norout'); })); }); + + describe('testability', function() { + it('should wait for $resolve promises before calling callbacks', function() { + var deferred; + + module(function($routeProvider) { + $routeProvider.when('/path', { + resolve: { + a: function($q) { + deferred = $q.defer(); + return deferred.promise; + } + } + }); + }); + + inject(function($browser, $location, $rootScope, $$testability) { + $location.path('/path'); + $rootScope.$digest(); + + var callback = jasmine.createSpy('callback'); + $$testability.whenStable(callback); + expect(callback).not.toHaveBeenCalled(); + + deferred.resolve(); + $browser.defer.flush(); + expect(callback).toHaveBeenCalled(); + }); + }); + + it('should call callback after $resolve promises are rejected', function() { + var deferred; + + module(function($routeProvider) { + $routeProvider.when('/path', { + resolve: { + a: function($q) { + deferred = $q.defer(); + return deferred.promise; + } + } + }); + }); + + inject(function($browser, $location, $rootScope, $$testability) { + $location.path('/path'); + $rootScope.$digest(); + + var callback = jasmine.createSpy('callback'); + $$testability.whenStable(callback); + expect(callback).not.toHaveBeenCalled(); + + deferred.reject(); + $browser.defer.flush(); + expect(callback).toHaveBeenCalled(); + }); + }); + + it('should wait for resolveRedirectTo promises before calling callbacks', function() { + var deferred; + + module(function($routeProvider) { + $routeProvider.when('/path', { + resolveRedirectTo: function($q) { + deferred = $q.defer(); + return deferred.promise; + } + }); + }); + + inject(function($browser, $location, $rootScope, $$testability) { + $location.path('/path'); + $rootScope.$digest(); + + var callback = jasmine.createSpy('callback'); + $$testability.whenStable(callback); + expect(callback).not.toHaveBeenCalled(); + + deferred.resolve(); + $browser.defer.flush(); + expect(callback).toHaveBeenCalled(); + }); + }); + + it('should call callback after resolveRedirectTo promises are rejected', function() { + var deferred; + + module(function($routeProvider) { + $routeProvider.when('/path', { + resolveRedirectTo: function($q) { + deferred = $q.defer(); + return deferred.promise; + } + }); + }); + + inject(function($browser, $location, $rootScope, $$testability) { + $location.path('/path'); + $rootScope.$digest(); + + var callback = jasmine.createSpy('callback'); + $$testability.whenStable(callback); + expect(callback).not.toHaveBeenCalled(); + + deferred.reject(); + $browser.defer.flush(); + expect(callback).toHaveBeenCalled(); + }); + }); + + it('should wait for all route promises before calling callbacks', function() { + var deferreds = {}; + + module(function($routeProvider) { + addRouteWithAsyncRedirect('/foo', '/bar'); + addRouteWithAsyncRedirect('/bar', '/baz'); + addRouteWithAsyncRedirect('/baz', '/qux'); + $routeProvider.when('/qux', { + resolve: { + a: function($q) { + var deferred = deferreds['/qux'] = $q.defer(); + return deferred.promise; + } + } + }); + + // Helpers + function addRouteWithAsyncRedirect(fromPath, toPath) { + $routeProvider.when(fromPath, { + resolveRedirectTo: function($q) { + var deferred = deferreds[fromPath] = $q.defer(); + return deferred.promise.then(function() { return toPath; }); + } + }); + } + }); + + inject(function($browser, $location, $rootScope, $$testability) { + $location.path('/foo'); + $rootScope.$digest(); + + var callback = jasmine.createSpy('callback'); + $$testability.whenStable(callback); + expect(callback).not.toHaveBeenCalled(); + + deferreds['/foo'].resolve(); + $browser.defer.flush(); + expect(callback).not.toHaveBeenCalled(); + + deferreds['/bar'].resolve(); + $browser.defer.flush(); + expect(callback).not.toHaveBeenCalled(); + + deferreds['/baz'].resolve(); + $browser.defer.flush(); + expect(callback).not.toHaveBeenCalled(); + + deferreds['/qux'].resolve(); + $browser.defer.flush(); + expect(callback).toHaveBeenCalled(); + }); + }); + }); }); diff --git a/test/ngSanitize/directive/ngBindHtmlSpec.js b/test/ngSanitize/directive/ngBindHtmlSpec.js index 6759c6d20fcd..74eb0f231baa 100644 --- a/test/ngSanitize/directive/ngBindHtmlSpec.js +++ b/test/ngSanitize/directive/ngBindHtmlSpec.js @@ -8,7 +8,7 @@ describe('ngBindHtml', function() { var element = $compile('
          ')($rootScope); $rootScope.html = '
          hello
          '; $rootScope.$digest(); - expect(angular.lowercase(element.html())).toEqual('
          hello
          '); + expect(lowercase(element.html())).toEqual('
          hello
          '); })); @@ -18,11 +18,11 @@ describe('ngBindHtml', function() { angular.forEach([null, undefined, ''], function(val) { $rootScope.html = 'some val'; $rootScope.$digest(); - expect(angular.lowercase(element.html())).toEqual('some val'); + expect(lowercase(element.html())).toEqual('some val'); $rootScope.html = val; $rootScope.$digest(); - expect(angular.lowercase(element.html())).toEqual(''); + expect(lowercase(element.html())).toEqual(''); }); })); }); diff --git a/test/ngSanitize/filter/linkySpec.js b/test/ngSanitize/filter/linkySpec.js index baac45bd6d82..236766e61038 100644 --- a/test/ngSanitize/filter/linkySpec.js +++ b/test/ngSanitize/filter/linkySpec.js @@ -10,7 +10,7 @@ describe('linky', function() { })); it('should do basic filter', function() { - expect(linky("http://ab/%20(http://a/)%20%3Chttp://a/%3E%20http://1.2/v:~-123.%20c%20%E2%80%9Chttp://example.com%E2%80%9D%20%E2%80%98http://me.com%E2%80%99")). + expect(linky('http://ab/%20(http://a/)%20%3Chttp://a/%3E%20http://1.2/v:~-123.%20c%20%E2%80%9Chttp://example.com%E2%80%9D%20%E2%80%98http://me.com%E2%80%99')). toEqual('http://ab/ ' + '(
          http://a/) ' + '<http://a/> ' + @@ -20,18 +20,62 @@ describe('linky', function() { expect(linky(undefined)).not.toBeDefined(); }); + it('should return `undefined`/`null`/`""` values unchanged', function() { + expect(linky(undefined)).toBeUndefined(); + expect(linky(null)).toBe(null); + expect(linky('')).toBe(''); + }); + + it('should throw an error when used with a non-string value (other than `undefined`/`null`)', + function() { + expect(function() { linky(false); }). + toThrowMinErr('linky', 'notstring', 'Expected string but received: false'); + + expect(function() { linky(true); }). + toThrowMinErr('linky', 'notstring', 'Expected string but received: true'); + + expect(function() { linky(0); }). + toThrowMinErr('linky', 'notstring', 'Expected string but received: 0'); + + expect(function() { linky(42); }). + toThrowMinErr('linky', 'notstring', 'Expected string but received: 42'); + + expect(function() { linky({}); }). + toThrowMinErr('linky', 'notstring', 'Expected string but received: {}'); + + expect(function() { linky([]); }). + toThrowMinErr('linky', 'notstring', 'Expected string but received: []'); + + expect(function() { linky(noop); }). + toThrowMinErr('linky', 'notstring', 'Expected string but received: function noop()'); + } + ); + + it('should be case-insensitive', function() { + expect(linky('WWW.example.com')).toEqual('WWW.example.com'); + expect(linky('WWW.EXAMPLE.COM')).toEqual('WWW.EXAMPLE.COM'); + expect(linky('HTTP://www.example.com')).toEqual('HTTP://www.example.com'); + expect(linky('HTTP://example.com')).toEqual('HTTP://example.com'); + expect(linky('HTTPS://www.example.com')).toEqual('HTTPS://www.example.com'); + expect(linky('HTTPS://example.com')).toEqual('HTTPS://example.com'); + expect(linky('FTP://www.example.com')).toEqual('FTP://www.example.com'); + expect(linky('FTP://example.com')).toEqual('FTP://example.com'); + expect(linky('SFTP://www.example.com')).toEqual('SFTP://www.example.com'); + expect(linky('SFTP://example.com')).toEqual('SFTP://example.com'); + }); + it('should handle www.', function() { expect(linky('www.example.com')).toEqual('www.example.com'); }); it('should handle mailto:', function() { - expect(linky("mailto:me@example.com")). + expect(linky('mailto:me@example.com')). toEqual('me@example.com'); - expect(linky("me@example.com")). + expect(linky('me@example.com')). toEqual('me@example.com'); - expect(linky("send email to me@example.com, but")). + expect(linky('send email to me@example.com, but')). toEqual('send email to me@example.com, but'); - expect(linky("my email is \"me@example.com\"")). + expect(linky('my email is "me@example.com"')). toEqual('my email is "me@example.com"'); }); @@ -40,9 +84,57 @@ describe('linky', function() { }); it('should handle target:', function() { - expect(linky("http://example.com/", "_blank")). - toEqual('http://example.com'); - expect(linky("http://example.com/", "someNamedIFrame")). - toEqual('http://example.com'); + expect(linky('http://example.com/', '_blank')). + toBeOneOf('http://example.com', + 'http://example.com'); + expect(linky('http://example.com/', 'someNamedIFrame')). + toBeOneOf('http://example.com', + 'http://example.com'); + }); + + describe('custom attributes', function() { + + it('should optionally add custom attributes', function() { + expect(linky('http://example.com/', '_self', {rel: 'nofollow'})). + toBeOneOf('http://example.com', + 'http://example.com'); + }); + + + it('should override target parameter with custom attributes', function() { + expect(linky('http://example.com/', '_self', {target: '_blank'})). + toBeOneOf('http://example.com', + 'http://example.com'); + }); + + + it('should optionally add custom attributes from function', function() { + expect(linky('http://example.com/', '_self', function(url) {return {'class': 'blue'};})). + toBeOneOf('http://example.com', + 'http://example.com', + 'http://example.com'); + }); + + + it('should pass url as parameter to custom attribute function', function() { + var linkParameters = jasmine.createSpy('linkParameters').and.returnValue({'class': 'blue'}); + linky('http://example.com/', '_self', linkParameters); + expect(linkParameters).toHaveBeenCalledWith('http://example.com/'); + }); + + + it('should call the attribute function for all links in the input', function() { + var attributeFn = jasmine.createSpy('attributeFn').and.returnValue({}); + linky('http://example.com and http://google.com', '_self', attributeFn); + expect(attributeFn.calls.allArgs()).toEqual([['http://example.com/'], ['http://google.com/']]); + }); + + + it('should strip unsafe attributes', function() { + expect(linky('http://example.com/', '_self', {'class': 'blue', 'onclick': 'alert(\'Hi\')'})). + toBeOneOf('http://example.com', + 'http://example.com', + 'http://example.com'); + }); }); }); diff --git a/test/ngSanitize/sanitizeSpec.js b/test/ngSanitize/sanitizeSpec.js index 121d3284736f..ac3c44b3d59c 100644 --- a/test/ngSanitize/sanitizeSpec.js +++ b/test/ngSanitize/sanitizeSpec.js @@ -1,6 +1,8 @@ 'use strict'; describe('HTML', function() { + var ua = window.navigator.userAgent; + var isChrome = /Chrome/.test(ua) && !/Edge/.test(ua); var expectHTML; @@ -17,23 +19,25 @@ describe('HTML', function() { describe('htmlParser', function() { /* global htmlParser */ - if (angular.isUndefined(window.htmlParser)) return; var handler, start, text, comment; beforeEach(function() { - text = ""; + text = ''; + start = null; handler = { - start: function(tag, attrs, unary) { + start: function(tag, attrs) { start = { tag: tag, - attrs: attrs, - unary: unary + attrs: attrs }; // Since different browsers handle newlines differently we trim // so that it is easier to write tests. - angular.forEach(attrs, function(value, key) { + for (var i = 0, ii = attrs.length; i < ii; i++) { + var keyValue = attrs[i]; + var key = keyValue.key; + var value = keyValue.value; attrs[key] = value.replace(/^\s*/, '').replace(/\s*$/, ''); - }); + } }, chars: function(text_) { text += text_; @@ -45,40 +49,18 @@ describe('HTML', function() { comment = comment_; } }; + // Trigger the $sanitizer provider to execute, which initializes the `htmlParser` function. + inject(function($sanitize) {}); }); - it('should parse comments', function() { + it('should not parse comments', function() { htmlParser('', handler); - expect(comment).toEqual('FOOBAR'); - }); - - it('should throw an exception for invalid comments', function() { - var caught=false; - try { - htmlParser('', handler); - } - catch (ex) { - caught = true; - // expected an exception due to a bad parse - } - expect(caught).toBe(true); - }); - - it('double-dashes are not allowed in a comment', function() { - var caught=false; - try { - htmlParser('', handler); - } - catch (ex) { - caught = true; - // expected an exception due to a bad parse - } - expect(caught).toBe(true); + expect(comment).not.toBeDefined(); }); it('should parse basic format', function() { htmlParser('text', handler); - expect(start).toEqual({tag:'tag', attrs:{attr:'value'}, unary:false}); + expect(start).toEqual({tag:'tag', attrs:{attr:'value'}}); expect(text).toEqual('text'); }); @@ -87,18 +69,6 @@ describe('HTML', function() { toBe('<- text1 text2 <1 text1 text2 <{'); }); - it('should throw badparse if text content contains "<" followed by "/" without matching ">"', function() { - expect(function() { - htmlParser('foo "', function() { - expect(function() { - htmlParser('foo 10 < 100

          ', handler); @@ -107,25 +77,25 @@ describe('HTML', function() { it('should parse newlines in tags', function() { htmlParser('text', handler); - expect(start).toEqual({tag:'tag', attrs:{attr:'value'}, unary:false}); + expect(start).toEqual({tag:'tag', attrs:{attr:'value'}}); expect(text).toEqual('text'); }); it('should parse newlines in attributes', function() { htmlParser('text', handler); - expect(start).toEqual({tag:'tag', attrs:{attr:'value'}, unary:false}); + expect(start).toEqual({tag:'tag', attrs:{attr:'\nvalue\n'}}); expect(text).toEqual('text'); }); it('should parse namespace', function() { htmlParser('text', handler); - expect(start).toEqual({tag:'ns:t-a-g', attrs:{'ns:a-t-t-r':'value'}, unary:false}); + expect(start).toEqual({tag:'ns:t-a-g', attrs:{'ns:a-t-t-r':'\nvalue\n'}}); expect(text).toEqual('text'); }); it('should parse empty value attribute of node', function() { - htmlParser('', handler); - expect(start).toEqual({tag:'option', attrs:{selected:'', value:''}, unary:false}); + htmlParser('abc', handler); + expect(start).toEqual({tag:'test-foo', attrs:{selected:'', value:''}}); expect(text).toEqual('abc'); }); }); @@ -133,15 +103,17 @@ describe('HTML', function() { // THESE TESTS ARE EXECUTED WITH COMPILED ANGULAR it('should echo html', function() { expectHTML('helloworld.'). - toEqual('helloworld.'); + toBeOneOf('helloworld.', + 'helloworld.'); }); it('should remove script', function() { - expectHTML('ac.').toEqual('ac.'); }); it('should remove script that has newline characters', function() { - expectHTML('a\n\revil\n\r< / scrIpt\n >c.').toEqual('ac.'); + expectHTML('a\n\revil\n\rc.').toEqual('ac.'); }); it('should remove DOCTYPE header', function() { @@ -160,6 +132,17 @@ describe('HTML', function() { expectHTML('a
          b
          c').toEqual('a
          b
          c'); }); + it('should handle large datasets', function() { + // Large is non-trivial to quantify, but handling ~100,000 should be sufficient for most purposes. + var largeNumber = 17; // 2^17 = 131,072 + var result = '
          b
          '; + // Ideally we would use repeat, but that isn't supported in IE. + for (var i = 0; i < largeNumber; i++) { + result += result; + } + expectHTML('a' + result + 'c').toEqual('a' + result + 'c'); + }); + it('should remove style', function() { expectHTML('ac.').toEqual('ac.'); }); @@ -173,7 +156,7 @@ describe('HTML', function() { }); it('should remove double nested script', function() { - expectHTML('ailc.').toEqual('ac.'); + expectHTML('ailc.').toEqual('ailc.'); }); it('should remove unknown names', function() { @@ -182,10 +165,11 @@ describe('HTML', function() { it('should remove unsafe value', function() { expectHTML('
          ').toEqual(''); + expectHTML('').toEqual(''); }); it('should handle self closed elements', function() { - expectHTML('a
          c').toEqual('a
          c'); + expectHTML('a
          c').toEqual('a
          c'); }); it('should handle namespace', function() { @@ -212,7 +196,8 @@ describe('HTML', function() { it('should ignore back slash as escape', function() { expectHTML('xxx\\'). - toEqual('xxx\\'); + toBeOneOf('xxx\\', + 'xxx\\'); }); it('should ignore object attributes', function() { @@ -246,42 +231,204 @@ describe('HTML', function() { expectHTML(false).toBe('false'); }); - it('should accept SVG tags', function() { - expectHTML('') - .toEqual(''); + + it('should strip svg elements if not enabled via provider', function() { + expectHTML('') + .toEqual(''); + }); + + it('should prevent mXSS attacks', function() { + expectHTML('CLICKME').toBe('CLICKME'); + }); + + it('should strip html comments', function() { + expectHTML('

          text1text2

          ') + .toEqual('

          text1text2

          '); }); - it('should not ignore white-listed svg camelCased attributes', function() { - expectHTML('') - .toEqual(''); + describe('clobbered elements', function() { + + it('should throw on a form with an input named "parentNode"', function() { + inject(function($sanitize) { + + expect(function() { + $sanitize('
          '); + }).toThrowMinErr('$sanitize', 'elclob'); + + expect(function() { + $sanitize('
          '); + }).toThrowMinErr('$sanitize', 'elclob'); + }); + }); + + if (!/Edge\/\d{2,}/.test(window.navigator.userAgent)) { + // Skip test on Edge due to a browser bug. + it('should throw on a form with an input named "nextSibling"', function() { + inject(function($sanitize) { + + expect(function() { + $sanitize('
          '); + }).toThrowMinErr('$sanitize', 'elclob'); + + expect(function() { + $sanitize('
          '); + }).toThrowMinErr('$sanitize', 'elclob'); + }); + }); + } }); - it('should sanitize SVG xlink:href attribute values', function() { - expectHTML('') - .toEqual(''); + // See https://github.com/cure53/DOMPurify/blob/a992d3a75031cb8bb032e5ea8399ba972bdf9a65/src/purify.js#L439-L449 + it('should not allow JavaScript execution when creating inert document', inject(function($sanitize) { + $sanitize(''); + + expect(window.xxx).toBe(undefined); + delete window.xxx; + })); + + // See https://github.com/cure53/DOMPurify/releases/tag/0.6.7 + it('should not allow JavaScript hidden in badly formed HTML to get through sanitization (Firefox bug)', inject(function($sanitize) { + var doc = $sanitize('