diff --git a/.github/workflows/e2e-next.yml b/.github/workflows/e2e-next.yml index 4244beac2a..b435fe4119 100644 --- a/.github/workflows/e2e-next.yml +++ b/.github/workflows/e2e-next.yml @@ -6,19 +6,77 @@ on: branches: [main] jobs: + setup: + runs-on: ubuntu-latest + outputs: + test-files: ${{ steps['set-test-files'].outputs['test-files'] }} + steps: + - uses: actions/checkout@v3 + - run: npm install + - id: set-test-files + name: Get test files + # Extracts the list of all test files as JSON and trims to be relative to the test dir to be easier to read + run: + echo "test-files=$(npx jest -c test/e2e/jest.config.js --listTests --json | jq -cM 'map(.[env.PWD | length + + 10:])')" >> $GITHUB_OUTPUT + # echo "test-files=$(npx jest -c test/e2e/jest.config.all.js --listTests --json | jq -cM 'map(.[env.PWD | length + # + 10:])')" >> $GITHUB_OUTPUT + test: - name: E2E tests runs-on: ubuntu-latest + name: test (${{ matrix.test-file }}) + needs: + - setup + strategy: + fail-fast: false + matrix: + # Creates a job for each chunk ID. This will be assigned one or more test files to run + test-file: ${{ fromJson(needs.setup.outputs['test-files']) }} steps: - - uses: actions/checkout@v2 - - name: Installing with LTS Node.js - uses: actions/setup-node@v2 - with: - node-version: 'lts/*' - - name: NPM Install - run: npm install - - name: Run Next.js e2e test suite - run: npm run test:next + - uses: actions/checkout@v3 + - run: npm install + - name: Run tests + run: npx jest --reporters=jest-junit --reporters=default -c test/e2e/jest.config.all.js ${{ matrix.test-file }} env: NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_BOT_AUTH_TOKEN }} NETLIFY_SITE_ID: 1d5a5c76-d445-4ae5-b694-b0d3f2e2c395 + # RUN_SKIPPED_TESTS: true + # - uses: phoenix-actions/test-reporting@v10 + # if: ${{ always() }} + # name: Report Test Results + # # Generates annotations for the test failures + # id: test-report + # with: + # name: E2E Test chunk ${{ matrix.chunk }} + # path: 'reports/**/*.xml' # Path to test results (inside artifact .zip) + # output-to: 'checks' + # max-annotations: 49 # Maximum number of annotations to be created + # reporter: jest-junit # Format of test results + + - uses: actions/upload-artifact@v3 + # upload test results + if: ${{ always() }} + name: Upload test results + with: + name: test-results + path: reports/jest-*.xml + report: + runs-on: ubuntu-latest + if: ${{ always() }} + needs: + - test + steps: + - uses: actions/checkout@v3 + - uses: actions/download-artifact@v3 + with: + path: reports + - name: Combine reports + # The test reporter can handle multiple files, but these have random filenames so the output is better when combined + run: npx junit-report-merger test-results.xml reports/**/*.xml + - uses: phoenix-actions/test-reporting@v10 + with: + name: Jest Tests + output-to: 'step-summary' + path: 'test-results.xml' + max-annotations: 49 + reporter: jest-junit diff --git a/.gitignore b/.gitignore index 37fa1f60e3..97143df73e 100644 --- a/.gitignore +++ b/.gitignore @@ -150,3 +150,6 @@ packages/*/lib # Cypress cypress/screenshots + +# Test cases have node module fixtures +!test/**/node_modules \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index dbd6465334..1fed9d5f8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,7 @@ "husky": "^7.0.4", "jest": "^27.0.0", "jest-fetch-mock": "^3.0.3", + "jest-junit": "^14.0.1", "netlify-plugin-cypress": "^2.2.0", "npm-run-all": "^4.1.5", "playwright-chromium": "^1.26.1", @@ -4356,40 +4357,6 @@ "node": ">=16.0.0" } }, - "node_modules/@netlify/eslint-config-node/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@netlify/eslint-config-node/node_modules/eslint-plugin-n": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-14.0.0.tgz", - "integrity": "sha512-mNwplPLsbaKhHyA0fa/cy8j+oF6bF6l81hzBTWa6JOvPcMNAuIogk2ih6d9tYvWYzyUG+7ZFeChqbzdFpg2QrQ==", - "dev": true, - "dependencies": { - "eslint-plugin-es": "^4.1.0", - "eslint-utils": "^3.0.0", - "ignore": "^5.1.1", - "is-core-module": "^2.3.0", - "minimatch": "^3.0.4", - "resolve": "^1.10.1", - "semver": "^6.1.0" - }, - "engines": { - "node": ">=12.22.0" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - }, - "peerDependencies": { - "eslint": ">=7.0.0" - } - }, "node_modules/@netlify/eslint-config-node/node_modules/execa": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/execa/-/execa-6.1.0.tgz", @@ -4449,18 +4416,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@netlify/eslint-config-node/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/@netlify/eslint-config-node/node_modules/npm-run-path": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.1.0.tgz", @@ -4491,23 +4446,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@netlify/eslint-config-node/node_modules/resolve": { - "version": "1.22.1", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", - "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==", - "dev": true, - "dependencies": { - "is-core-module": "^2.9.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/@netlify/eslint-config-node/node_modules/strip-final-newline": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", @@ -5741,13 +5679,13 @@ "version": "15.7.5", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", - "devOptional": true + "dev": true }, "node_modules/@types/react": { "version": "18.0.25", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.25.tgz", "integrity": "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g==", - "devOptional": true, + "dev": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -5773,7 +5711,7 @@ "version": "0.16.2", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", - "devOptional": true + "dev": true }, "node_modules/@types/semver": { "version": "7.3.13", @@ -9608,7 +9546,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz", "integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==", - "devOptional": true + "dev": true }, "node_modules/custom-routes": { "resolved": "demos/custom-routes", @@ -11493,20 +11431,18 @@ } }, "node_modules/eslint-plugin-n": { - "version": "15.5.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-15.5.1.tgz", - "integrity": "sha512-kAd+xhZm7brHoFLzKLB7/FGRFJNg/srmv67mqb7tto22rpr4wv/LV6RuXzAfv3jbab7+k1wi42PsIhGviywaaw==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-14.0.0.tgz", + "integrity": "sha512-mNwplPLsbaKhHyA0fa/cy8j+oF6bF6l81hzBTWa6JOvPcMNAuIogk2ih6d9tYvWYzyUG+7ZFeChqbzdFpg2QrQ==", "dev": true, - "peer": true, "dependencies": { - "builtins": "^5.0.1", "eslint-plugin-es": "^4.1.0", "eslint-utils": "^3.0.0", "ignore": "^5.1.1", - "is-core-module": "^2.11.0", - "minimatch": "^3.1.2", - "resolve": "^1.22.1", - "semver": "^7.3.8" + "is-core-module": "^2.3.0", + "minimatch": "^3.0.4", + "resolve": "^1.10.1", + "semver": "^6.1.0" }, "engines": { "node": ">=12.22.0" @@ -11523,7 +11459,6 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, - "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -11534,7 +11469,6 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, - "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -11547,7 +11481,6 @@ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==", "dev": true, - "peer": true, "dependencies": { "is-core-module": "^2.9.0", "path-parse": "^1.0.7", @@ -11560,22 +11493,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint-plugin-n/node_modules/semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", - "dev": true, - "peer": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/eslint-plugin-promise": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-6.1.1.tgz", @@ -13904,7 +13821,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.1.0.tgz", "integrity": "sha512-oNkuqVTA8jqG1Q6c+UglTOD1xhC1BtjKI7XkCXRkZHrN5m18/XsnUp8Q89GkQO/z+0WjonSvl0FLhDYftp46nQ==", - "devOptional": true + "dev": true }, "node_modules/import-fresh": { "version": "3.3.0", @@ -15322,6 +15239,33 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "node_modules/jest-junit": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/jest-junit/-/jest-junit-14.0.1.tgz", + "integrity": "sha512-h7/wwzPbllgpQhhVcRzRC76/cc89GlazThoV1fDxcALkf26IIlRsu/AcTG64f4nR2WPE3Cbd+i/sVf+NCUHrWQ==", + "dev": true, + "dependencies": { + "mkdirp": "^1.0.4", + "strip-ansi": "^6.0.1", + "uuid": "^8.3.2", + "xml": "^1.0.1" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/jest-junit/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jest-leak-detector": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-27.5.1.tgz", @@ -21461,7 +21405,7 @@ "version": "1.56.1", "resolved": "https://registry.npmjs.org/sass/-/sass-1.56.1.tgz", "integrity": "sha512-VpEyKpyBPCxE7qGDtOcdJ6fFbcpOM+Emu7uZLxVrkX8KVU/Dp5UF7WLvzqRuUhB6mqqQt1xffLoG+AndxTZrCQ==", - "devOptional": true, + "dev": true, "dependencies": { "chokidar": ">=3.0.0 <4.0.0", "immutable": "^4.0.0", @@ -24307,6 +24251,12 @@ "node": ">=8" } }, + "node_modules/xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", + "dev": true + }, "node_modules/xml-name-validator": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", @@ -27410,31 +27360,6 @@ "statuses": "^2.0.1" }, "dependencies": { - "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "eslint-plugin-n": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-14.0.0.tgz", - "integrity": "sha512-mNwplPLsbaKhHyA0fa/cy8j+oF6bF6l81hzBTWa6JOvPcMNAuIogk2ih6d9tYvWYzyUG+7ZFeChqbzdFpg2QrQ==", - "dev": true, - "requires": { - "eslint-plugin-es": "^4.1.0", - "eslint-utils": "^3.0.0", - "ignore": "^5.1.1", - "is-core-module": "^2.3.0", - "minimatch": "^3.0.4", - "resolve": "^1.10.1", - "semver": "^6.1.0" - } - }, "execa": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/execa/-/execa-6.1.0.tgz", @@ -27470,15 +27395,6 @@ "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", "dev": true }, - "minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "requires": { - "brace-expansion": "^1.1.7" - } - }, "npm-run-path": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.1.0.tgz", @@ -27497,17 +27413,6 @@ "mimic-fn": "^4.0.0" } }, - "resolve": { - "version": "1.22.1", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", - "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==", - "dev": true, - "requires": { - "is-core-module": "^2.9.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - } - }, "strip-final-newline": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", @@ -28451,13 +28356,13 @@ "version": "15.7.5", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", - "devOptional": true + "dev": true }, "@types/react": { "version": "18.0.25", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.25.tgz", "integrity": "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g==", - "devOptional": true, + "dev": true, "requires": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -28483,7 +28388,7 @@ "version": "0.16.2", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", - "devOptional": true + "dev": true }, "@types/semver": { "version": "7.3.13", @@ -28803,8 +28708,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "requires": {} + "dev": true }, "acorn-walk": { "version": "7.2.0", @@ -28861,8 +28765,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-3.0.0.tgz", "integrity": "sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ==", - "dev": true, - "requires": {} + "dev": true }, "ansi-align": { "version": "3.0.1", @@ -30906,8 +30809,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-4.2.0.tgz", "integrity": "sha512-NkANeMnaHrlaSSlpKGyvn2R4rqUDeE/9E5YHx+b4nwo0R8dZyAqcih8/gxpCZvqWP9Vf6xuLpMSzSgdVEIM78g==", - "dev": true, - "requires": {} + "dev": true }, "cp-file": { "version": "9.1.0", @@ -31451,7 +31353,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz", "integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==", - "devOptional": true + "dev": true }, "custom-routes": { "version": "file:demos/custom-routes", @@ -32636,15 +32538,13 @@ "version": "8.5.0", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.5.0.tgz", "integrity": "sha512-obmWKLUNCnhtQRKc+tmnYuQl0pFU1ibYJQ5BGhTVB08bHe9wC8qUeG7c08dj9XX+AuPj1YSGSQIHl1pnDHZR0Q==", - "dev": true, - "requires": {} + "dev": true }, "eslint-config-standard": { "version": "17.0.0", "resolved": "https://registry.npmjs.org/eslint-config-standard/-/eslint-config-standard-17.0.0.tgz", "integrity": "sha512-/2ks1GKyqSOkH7JFvXJicu0iMpoojkwB+f5Du/1SC0PtBL+s8v30k9njRZ21pm2drKYm2342jFnGWzttxPmZVg==", - "dev": true, - "requires": {} + "dev": true }, "eslint-formatter-codeframe": { "version": "7.32.1", @@ -33030,20 +32930,18 @@ } }, "eslint-plugin-n": { - "version": "15.5.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-15.5.1.tgz", - "integrity": "sha512-kAd+xhZm7brHoFLzKLB7/FGRFJNg/srmv67mqb7tto22rpr4wv/LV6RuXzAfv3jbab7+k1wi42PsIhGviywaaw==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-14.0.0.tgz", + "integrity": "sha512-mNwplPLsbaKhHyA0fa/cy8j+oF6bF6l81hzBTWa6JOvPcMNAuIogk2ih6d9tYvWYzyUG+7ZFeChqbzdFpg2QrQ==", "dev": true, - "peer": true, "requires": { - "builtins": "^5.0.1", "eslint-plugin-es": "^4.1.0", "eslint-utils": "^3.0.0", "ignore": "^5.1.1", - "is-core-module": "^2.11.0", - "minimatch": "^3.1.2", - "resolve": "^1.22.1", - "semver": "^7.3.8" + "is-core-module": "^2.3.0", + "minimatch": "^3.0.4", + "resolve": "^1.10.1", + "semver": "^6.1.0" }, "dependencies": { "brace-expansion": { @@ -33051,7 +32949,6 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, - "peer": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -33062,7 +32959,6 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, - "peer": true, "requires": { "brace-expansion": "^1.1.7" } @@ -33072,22 +32968,11 @@ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==", "dev": true, - "peer": true, "requires": { "is-core-module": "^2.9.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" } - }, - "semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", - "dev": true, - "peer": true, - "requires": { - "lru-cache": "^6.0.0" - } } } }, @@ -33095,8 +32980,7 @@ "version": "6.1.1", "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-6.1.1.tgz", "integrity": "sha512-tjqWDwVZQo7UIPMeDReOpUgHCmCiH+ePnVT+5zVapL0uuHnegBUs2smM13CzOs2Xb5+MHMRFTs9v24yjba4Oig==", - "dev": true, - "requires": {} + "dev": true }, "eslint-plugin-react": { "version": "7.31.10", @@ -33154,8 +33038,7 @@ "version": "4.6.0", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz", "integrity": "sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==", - "dev": true, - "requires": {} + "dev": true }, "eslint-plugin-unicorn": { "version": "43.0.2", @@ -34705,7 +34588,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.1.0.tgz", "integrity": "sha512-oNkuqVTA8jqG1Q6c+UglTOD1xhC1BtjKI7XkCXRkZHrN5m18/XsnUp8Q89GkQO/z+0WjonSvl0FLhDYftp46nQ==", - "devOptional": true + "dev": true }, "import-fresh": { "version": "3.3.0", @@ -35732,6 +35615,29 @@ "throat": "^6.0.1" } }, + "jest-junit": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/jest-junit/-/jest-junit-14.0.1.tgz", + "integrity": "sha512-h7/wwzPbllgpQhhVcRzRC76/cc89GlazThoV1fDxcALkf26IIlRsu/AcTG64f4nR2WPE3Cbd+i/sVf+NCUHrWQ==", + "dev": true, + "requires": { + "mkdirp": "^1.0.4", + "strip-ansi": "^6.0.1", + "uuid": "^8.3.2", + "xml": "^1.0.1" + }, + "dependencies": { + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.1" + } + } + } + }, "jest-leak-detector": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-27.5.1.tgz", @@ -35785,8 +35691,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", - "dev": true, - "requires": {} + "dev": true }, "jest-regex-util": { "version": "27.5.1", @@ -40388,7 +40293,7 @@ "version": "1.56.1", "resolved": "https://registry.npmjs.org/sass/-/sass-1.56.1.tgz", "integrity": "sha512-VpEyKpyBPCxE7qGDtOcdJ6fFbcpOM+Emu7uZLxVrkX8KVU/Dp5UF7WLvzqRuUhB6mqqQt1xffLoG+AndxTZrCQ==", - "devOptional": true, + "dev": true, "requires": { "chokidar": ">=3.0.0 <4.0.0", "immutable": "^4.0.0", @@ -41340,8 +41245,7 @@ "styled-jsx": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.0.7.tgz", - "integrity": "sha512-b3sUzamS086YLRuvnaDigdAewz1/EFYlHpYBP5mZovKEdQQOIIYq8lApylub3HHZ6xFjV051kkGU7cudJmrXEA==", - "requires": {} + "integrity": "sha512-b3sUzamS086YLRuvnaDigdAewz1/EFYlHpYBP5mZovKEdQQOIIYq8lApylub3HHZ6xFjV051kkGU7cudJmrXEA==" }, "supports-color": { "version": "9.2.3", @@ -42102,8 +42006,7 @@ "ws": { "version": "8.11.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", - "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", - "requires": {} + "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==" } } }, @@ -42231,8 +42134,7 @@ "use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", - "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", - "requires": {} + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==" }, "util-deprecate": { "version": "1.0.2", @@ -42596,8 +42498,7 @@ "version": "7.5.9", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", - "dev": true, - "requires": {} + "dev": true }, "xdg-basedir": { "version": "4.0.0", @@ -42605,6 +42506,12 @@ "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==", "dev": true }, + "xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", + "dev": true + }, "xml-name-validator": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", diff --git a/package.json b/package.json index f5297ffa9d..36e66a3c4f 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "install-husky": "if-env CI=1 || husky install node_modules/@netlify/eslint-config-node/.husky", "test": "run-s build:demo test:jest", "test:next": "jest -c test/e2e/jest.config.js", + "test:next:disabled": "RUN_SKIPPED_TESTS=1 jest -c test/e2e/jest.config.disabled.js", + "test:next:all": "RUN_SKIPPED_TESTS=1 jest -c test/e2e/jest.config.all.js", "test:jest": "jest", "playwright:install": "playwright install --with-deps chromium", "test:jest:update": "jest --updateSnapshot", @@ -69,6 +71,7 @@ "husky": "^7.0.4", "jest": "^27.0.0", "jest-fetch-mock": "^3.0.3", + "jest-junit": "^14.0.1", "netlify-plugin-cypress": "^2.2.0", "npm-run-all": "^4.1.5", "playwright-chromium": "^1.26.1", @@ -104,6 +107,15 @@ "verbose": true, "testTimeout": 60000 }, + "jest-junit": { + "outputDirectory": "reports", + "outputName": "jest-junit.xml", + "uniqueOutputName": "true", + "ancestorSeparator": " > ", + "suiteNameTemplate": "{filepath}", + "classNameTemplate": "{classname}", + "titleTemplate": "{title}" + }, "workspaces": [ "packages/*", "demos/canary", diff --git a/test/e2e/README.md b/test/e2e/README.md new file mode 100644 index 0000000000..84a57880c6 --- /dev/null +++ b/test/e2e/README.md @@ -0,0 +1,34 @@ +# Next.js e2e test suite + +The tests in this directory are taken from the Next.js monorepo. +[See the licence](https://github.com/vercel/next.js/blob/canary/license.md). The files in `tests` run unmodofied. The +ones in `modified-tests` either have fixes to run correctly, or have tests disabled. The tests in `disabled-tests` are +current disabled because of either an incompatibility or bug. + +The tools in `next-test-lib` are based on the equivalent tools in the Next.js monorepo. The original utilities test the +Next.js code, whereas here they are used to test the Netlify runtime. For this reason only the "deploy" mode is enabled, +and it builds using the runtime packages in this monorepo and deploys using the Netlify CLI. The e2e tests then run +against the deployed site. + +The script that runs these is in +[.github/workflows/e2e-next.yml](https://github.com/netlify/next-runtime/blob/main/.github/workflows/e2e-next.yml). It +runs all the tests in parallel using a matrix, with each running in a separate job. The tests are run against the +version of the Next Runtime that is currently in the monorepo, and the version of `next` that is in the monorepo's +dependencies. + +To run the tests locally, the npm script `test:next` can be used, which runs the default tests. The script +`test:next:all` runs all the tests, including the ones in `disabled-tests` and the other tests skipped by Jest. The +script `test:next:disabled` runs only the disabled tests. Unlike in CI, these are run sequentially, so will take a long +time to run, so you may want to just run a single test suite. To run a single suite run +`npm run test:next:all -- the-test-name-here`, e.g. `npm run test:next:all -- streaming-ssr`. The name is matched as a +pattern against the path of the test file and can be a partial match. + +When they are run, the tests generate the sites in a temporary directory, and then deploy them to Netlify. The e2e tests +are then run against these. This can be overridden by setting the env var `SITE_URL`, which will be used instead of +deploying the test site. This only makes sense when running a single test suite, because each test suite runs against a +different site. + +The tmp directory with each test site is deleted after the tests have been run. If you want to manually build or deploy +the test sites, you can run the test command, and then you can kill the test process after it has generated the site. +The location of the tmp directory is printed in the console. Alternatively, set the env var `NEXT_TEST_SKIP_CLEANUP` and +the site directories will all be retained. diff --git a/test/e2e/disabled-tests/edge-render-getserversideprops/app/next.config.js b/test/e2e/disabled-tests/edge-render-getserversideprops/app/next.config.js new file mode 100644 index 0000000000..abad3c69e6 --- /dev/null +++ b/test/e2e/disabled-tests/edge-render-getserversideprops/app/next.config.js @@ -0,0 +1,14 @@ +module.exports = { + rewrites() { + return [ + { + source: '/rewrite-me', + destination: '/', + }, + { + source: '/rewrite-me-dynamic', + destination: '/first', + }, + ] + }, +} diff --git a/test/e2e/disabled-tests/edge-render-getserversideprops/app/pages/[id].js b/test/e2e/disabled-tests/edge-render-getserversideprops/app/pages/[id].js new file mode 100644 index 0000000000..5d17ce2cb2 --- /dev/null +++ b/test/e2e/disabled-tests/edge-render-getserversideprops/app/pages/[id].js @@ -0,0 +1,23 @@ +export const config = { + runtime: 'experimental-edge', +} + +export default function Page(props) { + return ( + <> +

/[id]

+

{JSON.stringify(props)}

+ + ) +} + +export function getServerSideProps({ req, params, query }) { + return { + props: { + url: req.url, + query, + params, + now: Date.now(), + }, + } +} diff --git a/test/e2e/disabled-tests/edge-render-getserversideprops/app/pages/index.js b/test/e2e/disabled-tests/edge-render-getserversideprops/app/pages/index.js new file mode 100644 index 0000000000..c2380c49cc --- /dev/null +++ b/test/e2e/disabled-tests/edge-render-getserversideprops/app/pages/index.js @@ -0,0 +1,23 @@ +export const config = { + runtime: 'experimental-edge', +} + +export default function Page(props) { + return ( + <> +

/index

+

{JSON.stringify(props)}

+ + ) +} + +export function getServerSideProps({ req, params, query }) { + return { + props: { + url: req.url, + query, + now: Date.now(), + params: params || null, + }, + } +} diff --git a/test/e2e/disabled-tests/edge-render-getserversideprops/index.test.ts b/test/e2e/disabled-tests/edge-render-getserversideprops/index.test.ts new file mode 100644 index 0000000000..c3ea23aca7 --- /dev/null +++ b/test/e2e/disabled-tests/edge-render-getserversideprops/index.test.ts @@ -0,0 +1,129 @@ +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { fetchViaHTTP, normalizeRegEx, renderViaHTTP } from 'next-test-utils' +import cheerio from 'cheerio' +import { join } from 'path' +import escapeStringRegexp from 'escape-string-regexp' + +describe('edge-render-getserversideprops', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: new FileRef(join(__dirname, 'app')), + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('should have correct query/params on index', async () => { + const html = await renderViaHTTP(next.url, '/') + const $ = cheerio.load(html) + expect($('#page').text()).toBe('/index') + const props = JSON.parse($('#props').text()) + expect(props.query).toEqual({}) + expect(props.params).toBe(null) + expect(props.url).toBe('/') + }) + + it('should have correct query/params on /[id]', async () => { + const html = await renderViaHTTP(next.url, '/123', { hello: 'world' }) + const $ = cheerio.load(html) + expect($('#page').text()).toBe('/[id]') + const props = JSON.parse($('#props').text()) + expect(props.query).toEqual({ id: '123', hello: 'world' }) + expect(props.params).toEqual({ id: '123' }) + expect(props.url).toBe('/123?hello=world') + }) + + it('should have correct query/params on rewrite', async () => { + const html = await renderViaHTTP(next.url, '/rewrite-me', { + hello: 'world', + }) + const $ = cheerio.load(html) + expect($('#page').text()).toBe('/index') + const props = JSON.parse($('#props').text()) + expect(props.query).toEqual({ hello: 'world' }) + expect(props.params).toEqual(null) + expect(props.url).toBe('/rewrite-me?hello=world') + }) + + it('should have correct query/params on dynamic rewrite', async () => { + const html = await renderViaHTTP(next.url, '/rewrite-me-dynamic', { + hello: 'world', + }) + const $ = cheerio.load(html) + expect($('#page').text()).toBe('/[id]') + const props = JSON.parse($('#props').text()) + expect(props.query).toEqual({ id: 'first', hello: 'world' }) + expect(props.params).toEqual({ id: 'first' }) + expect(props.url).toBe('/rewrite-me-dynamic?hello=world') + }) + + it('should respond to _next/data for index correctly', async () => { + const res = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/index.json`, + undefined, + { + headers: { + 'x-nextjs-data': '1', + }, + } + ) + expect(res.status).toBe(200) + const { pageProps: props } = await res.json() + expect(props.query).toEqual({}) + expect(props.params).toBe(null) + }) + + it('should respond to _next/data for [id] correctly', async () => { + const res = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/321.json`, + { hello: 'world' }, + { + headers: { + 'x-nextjs-data': '1', + }, + } + ) + expect(res.status).toBe(200) + const { pageProps: props } = await res.json() + expect(props.query).toEqual({ id: '321', hello: 'world' }) + expect(props.params).toEqual({ id: '321' }) + }) + + if ((global as any).isNextStart) { + it('should have data routes in routes-manifest', async () => { + const manifest = JSON.parse( + await next.readFile('.next/routes-manifest.json') + ) + + for (const route of manifest.dataRoutes) { + route.dataRouteRegex = normalizeRegEx(route.dataRouteRegex) + } + + expect(manifest.dataRoutes).toEqual([ + { + dataRouteRegex: normalizeRegEx( + `^/_next/data/${escapeStringRegexp(next.buildId)}/index.json$` + ), + page: '/', + }, + { + dataRouteRegex: normalizeRegEx( + `^/_next/data/${escapeStringRegexp(next.buildId)}/([^/]+?)\\.json$` + ), + namedDataRouteRegex: `^/_next/data/${escapeStringRegexp( + next.buildId + )}/(?[^/]+?)\\.json$`, + page: '/[id]', + routeKeys: { + id: 'id', + }, + }, + ]) + }) + } +}) diff --git a/test/e2e/disabled-tests/middleware-request-header-overrides/app/.gitignore b/test/e2e/disabled-tests/middleware-request-header-overrides/app/.gitignore new file mode 100644 index 0000000000..e985853ed8 --- /dev/null +++ b/test/e2e/disabled-tests/middleware-request-header-overrides/app/.gitignore @@ -0,0 +1 @@ +.vercel diff --git a/test/e2e/disabled-tests/middleware-request-header-overrides/app/middleware.js b/test/e2e/disabled-tests/middleware-request-header-overrides/app/middleware.js new file mode 100644 index 0000000000..4421a4f37f --- /dev/null +++ b/test/e2e/disabled-tests/middleware-request-header-overrides/app/middleware.js @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server' + +/** + * @param {import('next/server').NextRequest} request + */ +export async function middleware(request) { + const headers = new Headers(request.headers) + headers.set('x-from-middleware', 'hello-from-middleware') + + const removeHeaders = request.nextUrl.searchParams.get('remove-headers') + if (removeHeaders) { + for (const key of removeHeaders.split(',')) { + headers.delete(key) + } + } + + const updateHeader = request.nextUrl.searchParams.get('update-headers') + if (updateHeader) { + for (const kv of updateHeader.split(',')) { + const [key, value] = kv.split('=') + headers.set(key, value) + } + } + + return NextResponse.next({ + request: { + headers, + }, + }) +} diff --git a/test/e2e/disabled-tests/middleware-request-header-overrides/app/next.config.js b/test/e2e/disabled-tests/middleware-request-header-overrides/app/next.config.js new file mode 100644 index 0000000000..4ba52ba2c8 --- /dev/null +++ b/test/e2e/disabled-tests/middleware-request-header-overrides/app/next.config.js @@ -0,0 +1 @@ +module.exports = {} diff --git a/test/e2e/disabled-tests/middleware-request-header-overrides/app/pages/api/dump-headers-edge.js b/test/e2e/disabled-tests/middleware-request-header-overrides/app/pages/api/dump-headers-edge.js new file mode 100644 index 0000000000..0ece8ea2c7 --- /dev/null +++ b/test/e2e/disabled-tests/middleware-request-header-overrides/app/pages/api/dump-headers-edge.js @@ -0,0 +1,11 @@ +export const config = { + runtime: 'experimental-edge', +} + +export default (req) => { + return Response.json(Object.fromEntries(req.headers.entries()), { + headers: { + 'headers-from-edge-function': '1', + }, + }) +} diff --git a/test/e2e/disabled-tests/middleware-request-header-overrides/app/pages/api/dump-headers-serverless.js b/test/e2e/disabled-tests/middleware-request-header-overrides/app/pages/api/dump-headers-serverless.js new file mode 100644 index 0000000000..0f1a9262d9 --- /dev/null +++ b/test/e2e/disabled-tests/middleware-request-header-overrides/app/pages/api/dump-headers-serverless.js @@ -0,0 +1,6 @@ +export default (req, res) => { + return res + .status(200) + .setHeader('headers-from-serverless', '1') + .json(req.headers) +} diff --git a/test/e2e/disabled-tests/middleware-request-header-overrides/app/pages/ssr-page.js b/test/e2e/disabled-tests/middleware-request-header-overrides/app/pages/ssr-page.js new file mode 100644 index 0000000000..ed2e4a6fcc --- /dev/null +++ b/test/e2e/disabled-tests/middleware-request-header-overrides/app/pages/ssr-page.js @@ -0,0 +1,15 @@ +export default function SSRPage({ headers }) { + return ( + <> +

{JSON.stringify(headers)}

+ + ) +} + +export const getServerSideProps = (ctx) => { + return { + props: { + headers: ctx.req.headers, + }, + } +} diff --git a/test/e2e/disabled-tests/middleware-request-header-overrides/test/index.test.ts b/test/e2e/disabled-tests/middleware-request-header-overrides/test/index.test.ts new file mode 100644 index 0000000000..03f7296b5b --- /dev/null +++ b/test/e2e/disabled-tests/middleware-request-header-overrides/test/index.test.ts @@ -0,0 +1,119 @@ +/* eslint-env jest */ + +import { join } from 'path' +import { NextInstance } from 'test/lib/next-modes/base' +import { fetchViaHTTP } from 'next-test-utils' +import { createNext, FileRef } from 'e2e-utils' +import cheerio from 'cheerio' + +describe('Middleware Request Headers Overrides', () => { + let next: NextInstance + + afterAll(() => next.destroy()) + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, '../app/pages')), + 'next.config.js': new FileRef(join(__dirname, '../app/next.config.js')), + 'middleware.js': new FileRef(join(__dirname, '../app/middleware.js')), + }, + }) + }) + + describe.each([ + { + title: 'Serverless Functions', + path: '/api/dump-headers-serverless', + toJson: (res: Response) => res.json(), + }, + { + title: 'Edge Functions', + path: '/api/dump-headers-edge', + toJson: (res: Response) => res.json(), + }, + { + title: 'getServerSideProps', + path: '/ssr-page', + toJson: async (res: Response) => { + const $ = cheerio.load(await res.text()) + return JSON.parse($('#headers').text()) + }, + }, + ])('$title Backend', ({ path, toJson }) => { + it(`Adds new headers`, async () => { + const res = await fetchViaHTTP(next.url, path, null, { + headers: { + 'x-from-client': 'hello-from-client', + }, + }) + expect(await toJson(res)).toMatchObject({ + 'x-from-client': 'hello-from-client', + 'x-from-middleware': 'hello-from-middleware', + }) + }) + + it(`Deletes headers`, async () => { + const res = await fetchViaHTTP( + next.url, + path, + { + 'remove-headers': 'x-from-client1,x-from-client2', + }, + { + headers: { + 'x-from-client1': 'hello-from-client', + 'X-From-Client2': 'hello-from-client', + }, + } + ) + + const json = await toJson(res) + expect(json).not.toHaveProperty('x-from-client1') + expect(json).not.toHaveProperty('X-From-Client2') + expect(json).toMatchObject({ + 'x-from-middleware': 'hello-from-middleware', + }) + + // Should not be included in response headers. + expect(res.headers.get('x-middleware-override-headers')).toBeNull() + expect( + res.headers.get('x-middleware-request-x-from-middleware') + ).toBeNull() + expect(res.headers.get('x-middleware-request-x-from-client1')).toBeNull() + expect(res.headers.get('x-middleware-request-x-from-client2')).toBeNull() + }) + + it(`Updates headers`, async () => { + const res = await fetchViaHTTP( + next.url, + path, + { + 'update-headers': + 'x-from-client1=new-value1,x-from-client2=new-value2', + }, + { + headers: { + 'x-from-client1': 'old-value1', + 'X-From-Client2': 'old-value2', + 'x-from-client3': 'old-value3', + }, + } + ) + expect(await toJson(res)).toMatchObject({ + 'x-from-client1': 'new-value1', + 'x-from-client2': 'new-value2', + 'x-from-client3': 'old-value3', + 'x-from-middleware': 'hello-from-middleware', + }) + + // Should not be included in response headers. + expect(res.headers.get('x-middleware-override-headers')).toBeNull() + expect( + res.headers.get('x-middleware-request-x-from-middleware') + ).toBeNull() + expect(res.headers.get('x-middleware-request-x-from-client1')).toBeNull() + expect(res.headers.get('x-middleware-request-x-from-client2')).toBeNull() + expect(res.headers.get('x-middleware-request-x-from-client3')).toBeNull() + }) + }) +}) diff --git a/test/e2e/disabled-tests/middleware-trailing-slash/app/middleware.js b/test/e2e/disabled-tests/middleware-trailing-slash/app/middleware.js new file mode 100644 index 0000000000..4d6d5b3204 --- /dev/null +++ b/test/e2e/disabled-tests/middleware-trailing-slash/app/middleware.js @@ -0,0 +1,106 @@ +import { NextResponse, URLPattern } from 'next/server' + +export async function middleware(request) { + const url = request.nextUrl + + // this is needed for tests to get the BUILD_ID + if (url.pathname.startsWith('/_next/static/__BUILD_ID')) { + return NextResponse.next() + } + + if (request.headers.get('x-prerender-revalidate')) { + return NextResponse.next({ + headers: { 'x-middleware': 'hi' }, + }) + } + + if (url.pathname === '/about/') { + return NextResponse.rewrite(new URL('/about/a', request.url)) + } + + if (url.pathname === '/ssr-page/') { + url.pathname = '/ssr-page-2' + return NextResponse.rewrite(url) + } + + if (url.pathname === '/') { + url.pathname = '/ssg/first' + return NextResponse.rewrite(url) + } + + if (url.pathname === '/to-ssg/') { + url.pathname = '/ssg/hello' + url.searchParams.set('from', 'middleware') + return NextResponse.rewrite(url) + } + + if (url.pathname === '/sha/') { + url.pathname = '/shallow' + return NextResponse.rewrite(url) + } + + if (url.pathname === '/rewrite-to-dynamic/') { + url.pathname = '/blog/from-middleware' + url.searchParams.set('some', 'middleware') + return NextResponse.rewrite(url) + } + + if (url.pathname === '/rewrite-to-config-rewrite/') { + url.pathname = '/rewrite-3' + url.searchParams.set('some', 'middleware') + return NextResponse.rewrite(url) + } + + if (url.pathname === '/redirect-to-somewhere/') { + url.pathname = '/somewhere' + return NextResponse.redirect(url, { + headers: { + 'x-redirect-header': 'hi', + }, + }) + } + + const original = new URL(request.url) + return NextResponse.next({ + headers: { + 'req-url-path': `${original.pathname}${original.search}`, + 'req-url-basepath': request.nextUrl.basePath, + 'req-url-pathname': request.nextUrl.pathname, + 'req-url-query': request.nextUrl.searchParams.get('foo'), + 'req-url-locale': request.nextUrl.locale, + 'req-url-params': + url.pathname !== '/static' ? JSON.stringify(params(request.url)) : '{}', + }, + }) +} + +const PATTERNS = [ + [ + new URLPattern({ pathname: '/:locale/:id' }), + ({ pathname }) => ({ + pathname: '/:locale/:id', + params: pathname.groups, + }), + ], + [ + new URLPattern({ pathname: '/:id' }), + ({ pathname }) => ({ + pathname: '/:id', + params: pathname.groups, + }), + ], +] + +const params = (url) => { + const input = url.split('?')[0] + let result = {} + + for (const [pattern, handler] of PATTERNS) { + const patternResult = pattern.exec(input) + if (patternResult !== null && 'pathname' in patternResult) { + result = handler(patternResult) + break + } + } + return result +} diff --git a/test/e2e/disabled-tests/middleware-trailing-slash/app/next.config.js b/test/e2e/disabled-tests/middleware-trailing-slash/app/next.config.js new file mode 100644 index 0000000000..0c482151c3 --- /dev/null +++ b/test/e2e/disabled-tests/middleware-trailing-slash/app/next.config.js @@ -0,0 +1,32 @@ +module.exports = { + trailingSlash: true, + redirects() { + return [ + { + source: '/redirect-1', + destination: '/somewhere/else/', + permanent: false, + }, + ] + }, + rewrites() { + return [ + { + source: '/rewrite-1', + destination: '/ssr-page?from=config', + }, + { + source: '/rewrite-2', + destination: '/about/a?from=next-config', + }, + { + source: '/sha', + destination: '/shallow', + }, + { + source: '/rewrite-3', + destination: '/blog/middleware-rewrite?hello=config', + }, + ] + }, +} diff --git a/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/[id].js b/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/[id].js new file mode 100644 index 0000000000..11f4614a67 --- /dev/null +++ b/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/[id].js @@ -0,0 +1,3 @@ +export default function Index() { + return

Dynamic route

+} diff --git a/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/_app.js b/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/_app.js new file mode 100644 index 0000000000..65e905445e --- /dev/null +++ b/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/_app.js @@ -0,0 +1,8 @@ +export default function App({ Component, pageProps }) { + if (!pageProps || typeof pageProps !== 'object') { + throw new Error( + `Invariant: received invalid pageProps in _app, received ${pageProps}` + ) + } + return +} diff --git a/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/about/a.js b/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/about/a.js new file mode 100644 index 0000000000..dfacd4eaff --- /dev/null +++ b/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/about/a.js @@ -0,0 +1,7 @@ +export default function AboutA() { + return ( +
+

AboutA

+
+ ) +} diff --git a/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/about/b.js b/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/about/b.js new file mode 100644 index 0000000000..7a44254c45 --- /dev/null +++ b/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/about/b.js @@ -0,0 +1,7 @@ +export default function AboutB() { + return ( +
+

AboutB

+
+ ) +} diff --git a/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/api/headers.js b/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/api/headers.js new file mode 100644 index 0000000000..0f65c82e9f --- /dev/null +++ b/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/api/headers.js @@ -0,0 +1,3 @@ +export default function handler(req, res) { + res.json({ url: req.url, headers: req.headers }) +} diff --git a/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/blog/[slug].js b/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/blog/[slug].js new file mode 100644 index 0000000000..590ca0dcc1 --- /dev/null +++ b/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/blog/[slug].js @@ -0,0 +1,23 @@ +import { useRouter } from 'next/router' + +export default function Page(props) { + const router = useRouter() + return ( + <> +

/blog/[slug]

+

{JSON.stringify(router.query)}

+

{router.pathname}

+

{router.asPath}

+

{JSON.stringify(props)}

+ + ) +} + +export function getServerSideProps({ params }) { + return { + props: { + now: Date.now(), + params, + }, + } +} diff --git a/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/error-throw.js b/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/error-throw.js new file mode 100644 index 0000000000..b8a8706e5f --- /dev/null +++ b/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/error-throw.js @@ -0,0 +1,12 @@ +export default function ThrowOnData({ message }) { + return ( +
+

Throw on data request

+

{message}

+
+ ) +} + +export const getServerSideProps = ({ query }) => ({ + props: { message: query.message || '' }, +}) diff --git a/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/error.js b/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/error.js new file mode 100644 index 0000000000..a5a49734cc --- /dev/null +++ b/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/error.js @@ -0,0 +1,11 @@ +import Link from 'next/link' + +export default function Errors() { + return ( +
+ + Throw on data + +
+ ) +} diff --git a/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/shallow.js b/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/shallow.js new file mode 100644 index 0000000000..4d1c8564ee --- /dev/null +++ b/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/shallow.js @@ -0,0 +1,43 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' + +export default function Shallow({ message }) { + const { pathname, query } = useRouter() + return ( +
+
    +
  • {message}
  • +
  • + + Shallow link to ?hello=world + +
  • +
  • + + Deep link to ?hello=goodbye + +
  • +
  • +

    + Current path: {pathname} +

    +
  • +
  • +

    + Current query: {JSON.stringify(query)} +

    +
  • +
+
+ ) +} + +let i = 0 + +export const getServerSideProps = () => { + return { + props: { + message: `Random: ${++i}${Math.random()}`, + }, + } +} diff --git a/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/ssg/[slug].js b/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/ssg/[slug].js new file mode 100644 index 0000000000..cf9fb1db0b --- /dev/null +++ b/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/ssg/[slug].js @@ -0,0 +1,42 @@ +import { useRouter } from 'next/router' +import { useEffect } from 'react' +import { useState } from 'react' + +export default function Page(props) { + const router = useRouter() + const [asPath, setAsPath] = useState( + router.isReady ? router.asPath : router.href + ) + + useEffect(() => { + if (router.isReady) { + setAsPath(router.asPath) + } + }, [router.asPath, router.isReady]) + + return ( + <> +

/blog/[slug]

+

{JSON.stringify(router.query)}

+

{router.pathname}

+

{asPath}

+

{JSON.stringify(props)}

+ + ) +} + +export function getStaticProps({ params }) { + return { + props: { + now: Date.now(), + params, + }, + } +} + +export function getStaticPaths() { + return { + paths: ['/ssg/first', '/ssg/hello'], + fallback: 'blocking', + } +} diff --git a/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/ssr-page-2.js b/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/ssr-page-2.js new file mode 100644 index 0000000000..67e1b70868 --- /dev/null +++ b/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/ssr-page-2.js @@ -0,0 +1,11 @@ +export default function SSRPage(props) { + return

{props.message}

+} + +export const getServerSideProps = (req) => { + return { + props: { + message: 'Bye Cruel World', + }, + } +} diff --git a/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/ssr-page.js b/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/ssr-page.js new file mode 100644 index 0000000000..e2aaaa56b4 --- /dev/null +++ b/test/e2e/disabled-tests/middleware-trailing-slash/app/pages/ssr-page.js @@ -0,0 +1,11 @@ +export default function SSRPage(props) { + return

{props.message}

+} + +export const getServerSideProps = (req) => { + return { + props: { + message: 'Hello World', + }, + } +} diff --git a/test/e2e/disabled-tests/middleware-trailing-slash/test/index.test.ts b/test/e2e/disabled-tests/middleware-trailing-slash/test/index.test.ts new file mode 100644 index 0000000000..58f545940a --- /dev/null +++ b/test/e2e/disabled-tests/middleware-trailing-slash/test/index.test.ts @@ -0,0 +1,412 @@ +/* eslint-env jest */ + +import fs from 'fs-extra' +import { join } from 'path' +import webdriver from 'next-webdriver' +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { check, fetchViaHTTP, waitFor } from 'next-test-utils' + +describe('Middleware Runtime trailing slash', () => { + let next: NextInstance + + afterAll(async () => { + await next.destroy() + }) + beforeAll(async () => { + next = await createNext({ + files: { + 'next.config.js': new FileRef(join(__dirname, '../app/next.config.js')), + 'middleware.js': new FileRef(join(__dirname, '../app/middleware.js')), + pages: new FileRef(join(__dirname, '../app/pages')), + }, + }) + }) + + function runTests() { + if ((global as any).isNextDev) { + it('refreshes the page when middleware changes ', async () => { + const browser = await webdriver(next.url, `/about/`) + await browser.eval('window.didrefresh = "hello"') + const text = await browser.elementByCss('h1').text() + expect(text).toEqual('AboutA') + + const middlewarePath = join(next.testDir, '/middleware.js') + const originalContent = fs.readFileSync(middlewarePath, 'utf-8') + const editedContent = originalContent.replace('/about/a', '/about/b') + + try { + fs.writeFileSync(middlewarePath, editedContent) + await waitFor(1000) + const textb = await browser.elementByCss('h1').text() + expect(await browser.eval('window.itdidnotrefresh')).not.toBe('hello') + expect(textb).toEqual('AboutB') + } finally { + fs.writeFileSync(middlewarePath, originalContent) + await browser.close() + } + }) + } + + if ((global as any).isNextStart) { + it('should have valid middleware field in manifest', async () => { + const manifest = await fs.readJSON( + join(next.testDir, '.next/server/middleware-manifest.json') + ) + expect(manifest.middleware).toEqual({ + '/': { + files: ['server/edge-runtime-webpack.js', 'server/middleware.js'], + name: 'middleware', + env: [], + page: '/', + matchers: [{ regexp: '^/.*$' }], + wasm: [], + assets: [], + }, + }) + }) + + it('should have correct files in manifest', async () => { + const manifest = await fs.readJSON( + join(next.testDir, '.next/server/middleware-manifest.json') + ) + for (const key of Object.keys(manifest.middleware)) { + const middleware = manifest.middleware[key] + expect(middleware.files).toContainEqual( + expect.stringContaining('server/edge-runtime-webpack') + ) + expect(middleware.files).not.toContainEqual( + expect.stringContaining('static/chunks/') + ) + } + }) + + it('should not run middleware for on-demand revalidate', async () => { + const bypassToken = ( + await fs.readJSON(join(next.testDir, '.next/prerender-manifest.json')) + ).preview.previewModeId + + const res = await fetchViaHTTP(next.url, '/ssg/first/', undefined, { + headers: { + 'x-prerender-revalidate': bypassToken, + }, + }) + expect(res.status).toBe(200) + expect(res.headers.get('x-middleware')).toBeFalsy() + expect(res.headers.get('x-nextjs-cache')).toBe('REVALIDATED') + }) + } + + it('should have init header for NextResponse.redirect', async () => { + const res = await fetchViaHTTP( + next.url, + '/redirect-to-somewhere/', + undefined, + { + redirect: 'manual', + } + ) + expect(res.status).toBe(307) + expect(new URL(res.headers.get('location'), 'http://n').pathname).toBe( + '/somewhere/' + ) + expect(res.headers.get('x-redirect-header')).toBe('hi') + }) + + it('should have correct query values for rewrite to ssg page', async () => { + const browser = await webdriver(next.url, '/to-ssg/') + await browser.eval('window.beforeNav = 1') + + await check(() => browser.elementByCss('body').text(), /\/to-ssg/) + + expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({ + slug: 'hello', + }) + expect( + JSON.parse(await browser.elementByCss('#props').text()).params + ).toEqual({ + slug: 'hello', + }) + expect(await browser.elementByCss('#pathname').text()).toBe('/ssg/[slug]') + expect(await browser.elementByCss('#as-path').text()).toBe('/to-ssg/') + }) + + it('should have correct dynamic route params on client-transition to dynamic route', async () => { + const browser = await webdriver(next.url, '/') + await browser.eval('window.beforeNav = 1') + await browser.eval('window.next.router.push("/blog/first")') + await browser.waitForElementByCss('#blog') + + expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({ + slug: 'first', + }) + expect( + JSON.parse(await browser.elementByCss('#props').text()).params + ).toEqual({ + slug: 'first', + }) + expect(await browser.elementByCss('#pathname').text()).toBe( + '/blog/[slug]' + ) + expect(await browser.elementByCss('#as-path').text()).toBe('/blog/first/') + + await browser.eval('window.next.router.push("/blog/second")') + await check(() => browser.elementByCss('body').text(), /"slug":"second"/) + + expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({ + slug: 'second', + }) + expect( + JSON.parse(await browser.elementByCss('#props').text()).params + ).toEqual({ + slug: 'second', + }) + expect(await browser.elementByCss('#pathname').text()).toBe( + '/blog/[slug]' + ) + expect(await browser.elementByCss('#as-path').text()).toBe( + '/blog/second/' + ) + }) + + it('should have correct dynamic route params for middleware rewrite to dynamic route', async () => { + const browser = await webdriver(next.url, '/') + await browser.eval('window.beforeNav = 1') + await browser.eval('window.next.router.push("/rewrite-to-dynamic")') + await browser.waitForElementByCss('#blog') + + expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({ + slug: 'from-middleware', + some: 'middleware', + }) + expect( + JSON.parse(await browser.elementByCss('#props').text()).params + ).toEqual({ + slug: 'from-middleware', + }) + expect(await browser.elementByCss('#pathname').text()).toBe( + '/blog/[slug]' + ) + expect(await browser.elementByCss('#as-path').text()).toBe( + '/rewrite-to-dynamic/' + ) + }) + + it('should have correct route params for chained rewrite from middleware to config rewrite', async () => { + const browser = await webdriver(next.url, '/') + await browser.eval('window.beforeNav = 1') + await browser.eval( + 'window.next.router.push("/rewrite-to-config-rewrite")' + ) + await browser.waitForElementByCss('#blog') + + expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({ + slug: 'middleware-rewrite', + hello: 'config', + some: 'middleware', + }) + expect( + JSON.parse(await browser.elementByCss('#props').text()).params + ).toEqual({ + slug: 'middleware-rewrite', + }) + expect(await browser.elementByCss('#pathname').text()).toBe( + '/blog/[slug]' + ) + expect(await browser.elementByCss('#as-path').text()).toBe( + '/rewrite-to-config-rewrite/' + ) + }) + + it('should have correct route params for rewrite from config dynamic route', async () => { + const browser = await webdriver(next.url, '/') + await browser.eval('window.beforeNav = 1') + await browser.eval('window.next.router.push("/rewrite-3")') + await browser.waitForElementByCss('#blog') + + expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({ + slug: 'middleware-rewrite', + hello: 'config', + }) + expect( + JSON.parse(await browser.elementByCss('#props').text()).params + ).toEqual({ + slug: 'middleware-rewrite', + }) + expect(await browser.elementByCss('#pathname').text()).toBe( + '/blog/[slug]' + ) + expect(await browser.elementByCss('#as-path').text()).toBe('/rewrite-3/') + }) + + it('should have correct route params for rewrite from config non-dynamic route', async () => { + const browser = await webdriver(next.url, '/') + await browser.eval('window.beforeNav = 1') + await browser.eval('window.next.router.push("/rewrite-1")') + + await check( + () => browser.eval('document.documentElement.innerHTML'), + /Hello World/ + ) + + expect(await browser.eval('window.next.router.query')).toEqual({ + from: 'config', + }) + }) + + it('should redirect the same for direct visit and client-transition', async () => { + const res = await fetchViaHTTP(next.url, `/redirect-1/`, undefined, { + redirect: 'manual', + }) + expect(res.status).toBe(307) + expect(new URL(res.headers.get('location'), 'http://n').pathname).toBe( + '/somewhere/else/' + ) + + const browser = await webdriver(next.url, `/`) + await browser.eval(`next.router.push('/redirect-1')`) + await check(async () => { + const pathname = await browser.eval('location.pathname') + return pathname === '/somewhere/else/' ? 'success' : pathname + }, 'success') + }) + + it('should rewrite the same for direct visit and client-transition', async () => { + const res = await fetchViaHTTP(next.url, `/rewrite-1/`) + expect(res.status).toBe(200) + expect(await res.text()).toContain('Hello World') + + const browser = await webdriver(next.url, `/`) + await browser.eval('window.beforeNav = 1') + await browser.eval(`next.router.push('/rewrite-1')`) + await check(async () => { + const content = await browser.eval('document.documentElement.innerHTML') + return content.includes('Hello World') ? 'success' : content + }, 'success') + expect(await browser.eval('window.beforeNav')).toBe(1) + }) + + it('should rewrite correctly for non-SSG/SSP page', async () => { + const res = await fetchViaHTTP(next.url, `/rewrite-2/`) + expect(res.status).toBe(200) + expect(await res.text()).toContain('AboutA') + + const browser = await webdriver(next.url, `/`) + await browser.eval(`next.router.push('/rewrite-2')`) + await check(async () => { + const content = await browser.eval('document.documentElement.innerHTML') + return content.includes('AboutA') ? 'success' : content + }, 'success') + }) + + it('should respond with 400 on decode failure', async () => { + const res = await fetchViaHTTP(next.url, `/%2/`) + expect(res.status).toBe(400) + + if ((global as any).isNextStart) { + expect(await res.text()).toContain('Bad Request') + } + }) + + it(`should validate & parse request url from any route`, async () => { + const res = await fetchViaHTTP(next.url, `/static/`) + + expect(res.headers.get('req-url-basepath')).toBeFalsy() + expect(res.headers.get('req-url-pathname')).toBe('/static/') + + const { pathname, params } = JSON.parse(res.headers.get('req-url-params')) + expect(pathname).toBe(undefined) + expect(params).toEqual(undefined) + + expect(res.headers.get('req-url-query')).not.toBe('bar') + }) + + it('should trigger middleware for data requests', async () => { + const browser = await webdriver(next.url, `/ssr-page`) + const text = await browser.elementByCss('h1').text() + expect(text).toEqual('Bye Cruel World') + const res = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/ssr-page.json`, + undefined, + { + headers: { + 'x-nextjs-data': '1', + }, + } + ) + const json = await res.json() + expect(json.pageProps.message).toEqual('Bye Cruel World') + }) + + it('should normalize data requests into page requests', async () => { + const res = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/send-url.json`, + undefined, + { + headers: { + 'x-nextjs-data': '1', + }, + } + ) + expect(res.headers.get('req-url-path')).toEqual('/send-url/') + }) + + it('should keep non data requests in their original shape', async () => { + const res = await fetchViaHTTP( + next.url, + `/_next/static/${next.buildId}/_devMiddlewareManifest.json?foo=1` + ) + expect(res.headers.get('req-url-path')).toEqual( + `/_next/static/${next.buildId}/_devMiddlewareManifest.json?foo=1` + ) + }) + + it('should add a rewrite header on data requests for rewrites', async () => { + const res = await fetchViaHTTP(next.url, `/ssr-page/`) + const dataRes = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/ssr-page.json`, + undefined, + { headers: { 'x-nextjs-data': '1' } } + ) + const json = await dataRes.json() + expect(json.pageProps.message).toEqual('Bye Cruel World') + expect(res.headers.get('x-nextjs-matched-path')).toBeNull() + expect(dataRes.headers.get('x-nextjs-matched-path')).toEqual( + `/ssr-page-2` + ) + }) + + it('allows shallow linking with middleware', async () => { + const browser = await webdriver(next.url, '/sha/') + const getMessageContents = () => + browser.elementById('message-contents').text() + const ssrMessage = await getMessageContents() + const requests: string[] = [] + + browser.on('request', (x) => { + requests.push(x.url()) + }) + + browser.elementById('deep-link').click() + browser.waitForElementByCss('[data-query-hello="goodbye"]') + const deepLinkMessage = await getMessageContents() + expect(deepLinkMessage).not.toEqual(ssrMessage) + + // Changing the route with a shallow link should not cause a server request + browser.elementById('shallow-link').click() + browser.waitForElementByCss('[data-query-hello="world"]') + expect(await getMessageContents()).toEqual(deepLinkMessage) + + // Check that no server requests were made to ?hello=world, + // as it's a shallow request. + expect(requests.filter((req) => req.includes('_next/data'))).toEqual([ + `${next.url}/_next/data/${next.buildId}/sha.json?hello=goodbye`, + ]) + }) + } + + runTests() +}) diff --git a/test/e2e/disabled-tests/proxy-request-with-middleware/app/middleware.js b/test/e2e/disabled-tests/proxy-request-with-middleware/app/middleware.js new file mode 100644 index 0000000000..e045282b9f --- /dev/null +++ b/test/e2e/disabled-tests/proxy-request-with-middleware/app/middleware.js @@ -0,0 +1,5 @@ +import { NextResponse } from 'next/server' + +export async function middleware() { + return NextResponse.next() +} diff --git a/test/e2e/disabled-tests/proxy-request-with-middleware/app/pages/api/index.js b/test/e2e/disabled-tests/proxy-request-with-middleware/app/pages/api/index.js new file mode 100644 index 0000000000..01c9f6d808 --- /dev/null +++ b/test/e2e/disabled-tests/proxy-request-with-middleware/app/pages/api/index.js @@ -0,0 +1,28 @@ +import request from 'request' + +export const config = { + api: { + bodyParser: false, + }, +} + +export default function handler(req, res) { + return req + .pipe( + request( + `http://${ + // node v18 resolves to IPv6 by default so force IPv4 + process.version.startsWith('v18.') + ? `127.0.0.1:${req.headers.host.split(':').pop() || ''}` + : req.headers.host + }${req.url}/post`, + { + followAllRedirects: false, + followRedirect: false, + gzip: true, + json: false, + } + ) + ) + .pipe(res) +} diff --git a/test/e2e/disabled-tests/proxy-request-with-middleware/app/pages/api/post.js b/test/e2e/disabled-tests/proxy-request-with-middleware/app/pages/api/post.js new file mode 100644 index 0000000000..172130f449 --- /dev/null +++ b/test/e2e/disabled-tests/proxy-request-with-middleware/app/pages/api/post.js @@ -0,0 +1,12 @@ +export const config = { + api: { + bodyParser: false, + }, +} + +export default function handler(req, res) { + return res.json({ + method: req.method, + headers: req.headers, + }) +} diff --git a/test/e2e/disabled-tests/proxy-request-with-middleware/test/index.test.ts b/test/e2e/disabled-tests/proxy-request-with-middleware/test/index.test.ts new file mode 100644 index 0000000000..3795ca3052 --- /dev/null +++ b/test/e2e/disabled-tests/proxy-request-with-middleware/test/index.test.ts @@ -0,0 +1,53 @@ +/* eslint-env jest */ + +import { join } from 'path' +import { fetchViaHTTP } from 'next-test-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { createNext, FileRef } from 'e2e-utils' + +describe('Requests not effected when middleware used', () => { + let next: NextInstance + + afterAll(() => next.destroy()) + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, '../app/pages')), + 'middleware.js': new FileRef(join(__dirname, '../app/middleware.js')), + }, + dependencies: { + request: '^2.88.2', + }, + }) + }) + + function sendRequest(method) { + const body = !['get', 'head'].includes(method.toLowerCase()) + ? JSON.stringify({ + key: 'value', + }) + : undefined + it(`should proxy ${method} request ${ + body ? 'with body' : '' + }`, async () => { + const headers = { + 'content-type': 'application/json', + 'x-custom-header': 'some value', + } + const res = await fetchViaHTTP(next.url, `api`, '', { + method: method.toUpperCase(), + headers, + body: method.toLowerCase() !== 'get' ? body : undefined, + }) + const data = await res.json() + expect(data.method).toEqual(method) + if (body) { + expect(data.headers['content-length']).toEqual(String(body.length)) + } + expect(data.headers).toEqual(expect.objectContaining(headers)) + }) + } + + sendRequest('GET') + sendRequest('POST') +}) diff --git a/test/e2e/jest.config.all.js b/test/e2e/jest.config.all.js new file mode 100644 index 0000000000..5e688d82a8 --- /dev/null +++ b/test/e2e/jest.config.all.js @@ -0,0 +1,11 @@ +// @ts-check +/** @type {import('@jest/types').Config.InitialOptions} */ + +const parent = require('./jest.config') + +const config = { + ...parent, + testMatch: ['**/test/e2e/**/*.test.js', '**/test/e2e/**/*.test.ts'], +} + +module.exports = config diff --git a/test/e2e/jest.config.disabled.js b/test/e2e/jest.config.disabled.js new file mode 100644 index 0000000000..59eb69a076 --- /dev/null +++ b/test/e2e/jest.config.disabled.js @@ -0,0 +1,11 @@ +// @ts-check +/** @type {import('@jest/types').Config.InitialOptions} */ + +const parent = require('./jest.config') + +const config = { + ...parent, + testMatch: ['**/test/e2e/disabled-tests/**/*.test.js', '**/test/e2e/disabled-tests/**/*.test.ts'], +} + +module.exports = config diff --git a/test/e2e/jest.config.js b/test/e2e/jest.config.js index b9fcbe1b58..c1960d0c0a 100644 --- a/test/e2e/jest.config.js +++ b/test/e2e/jest.config.js @@ -2,14 +2,20 @@ /** @type {import('@jest/types').Config.InitialOptions} */ const config = { + maxWorkers: 1, rootDir: __dirname, - setupFiles: ['../../jestSetup.js'], - testMatch: ['**/test/**/*.test.js', '**/test/**/*.test.ts'], + testMatch: [ + '**/test/e2e/tests/**/*.test.js', + '**/test/e2e/tests/**/*.test.ts', + '**/test/e2e/modified-tests/**/*.test.js', + '**/test/e2e/modified-tests/**/*.test.ts', + ], transform: { '\\.[jt]sx?$': 'babel-jest', }, verbose: true, testTimeout: 60000, + moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx'], moduleNameMapper: { 'e2e-utils': '/next-test-lib/e2e-utils.ts', 'test/lib/next-modes/base': '/next-test-lib/next-modes/base.ts', diff --git a/test/e2e/modified-tests/edge-can-read-request-body/app/.gitignore b/test/e2e/modified-tests/edge-can-read-request-body/app/.gitignore new file mode 100644 index 0000000000..e985853ed8 --- /dev/null +++ b/test/e2e/modified-tests/edge-can-read-request-body/app/.gitignore @@ -0,0 +1 @@ +.vercel diff --git a/test/e2e/modified-tests/edge-can-read-request-body/app/middleware.js b/test/e2e/modified-tests/edge-can-read-request-body/app/middleware.js new file mode 100644 index 0000000000..bbddddb07e --- /dev/null +++ b/test/e2e/modified-tests/edge-can-read-request-body/app/middleware.js @@ -0,0 +1,49 @@ +// @ts-check + +import { NextResponse } from 'next/server' + +/** + * @param {NextRequest} req + */ +export default async function middleware(req) { + const res = NextResponse.next() + res.headers.set('x-incoming-content-type', req.headers.get('content-type')) + + const handler = + bodyHandlers[req.nextUrl.searchParams.get('middleware-handler')] + const headers = await handler?.(req) + for (const [key, value] of headers ?? []) { + res.headers.set(key, value) + } + + return res +} + +/** + * @typedef {import('next/server').NextRequest} NextRequest + * @typedef {(req: NextRequest) => Promise<[string, string][]>} Handler + * @type {Record} + */ +const bodyHandlers = { + json: async (req) => { + const json = await req.json() + return [ + ['x-req-type', 'json'], + ['x-serialized', JSON.stringify(json)], + ] + }, + text: async (req) => { + const text = await req.text() + return [ + ['x-req-type', 'text'], + ['x-serialized', text], + ] + }, + formData: async (req) => { + const formData = await req.formData() + return [ + ['x-req-type', 'formData'], + ['x-serialized', JSON.stringify(Object.fromEntries(formData))], + ] + }, +} diff --git a/test/e2e/modified-tests/edge-can-read-request-body/app/pages/api/nothing.js b/test/e2e/modified-tests/edge-can-read-request-body/app/pages/api/nothing.js new file mode 100644 index 0000000000..7c595baeca --- /dev/null +++ b/test/e2e/modified-tests/edge-can-read-request-body/app/pages/api/nothing.js @@ -0,0 +1,3 @@ +export default (_req, res) => { + res.send('ok') +} diff --git a/test/e2e/modified-tests/edge-can-read-request-body/index.test.ts b/test/e2e/modified-tests/edge-can-read-request-body/index.test.ts new file mode 100644 index 0000000000..5c4bde573a --- /dev/null +++ b/test/e2e/modified-tests/edge-can-read-request-body/index.test.ts @@ -0,0 +1,104 @@ +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { fetchViaHTTP, renderViaHTTP } from 'next-test-utils' +import FormData from 'form-data' +import path from 'path' + +async function serialize(response: Response) { + return { + text: await response.text(), + headers: Object.fromEntries(response.headers), + status: response.status, + } +} + +describe('Edge can read request body', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: new FileRef(path.resolve(__dirname, './app')), + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('renders the static page', async () => { + const html = await renderViaHTTP(next.url, '/api/nothing') + expect(html).toContain('ok') + }) + // NTL Fail + ;(process.env.RUN_SKIPPED_TESTS ? describe : describe.skip)('middleware', () => { + it('reads a JSON body', async () => { + const response = await fetchViaHTTP(next.url, '/api/nothing?middleware-handler=json', null, { + method: 'POST', + body: JSON.stringify({ hello: 'world' }), + }) + expect(await serialize(response)).toMatchObject({ + text: expect.stringContaining('ok'), + status: 200, + headers: { + 'x-req-type': 'json', + 'x-serialized': '{"hello":"world"}', + }, + }) + }) + + it('reads a text body', async () => { + try { + const response = await fetchViaHTTP(next.url, '/api/nothing?middleware-handler=text', null, { + method: 'POST', + body: JSON.stringify({ hello: 'world' }), + }) + + expect(await serialize(response)).toMatchObject({ + text: expect.stringContaining('ok'), + status: 200, + headers: { + 'x-req-type': 'text', + 'x-serialized': '{"hello":"world"}', + }, + }) + } catch (err) { + console.log('FAILED', err) + } + }) + + it('reads an URL encoded form data', async () => { + const response = await fetchViaHTTP(next.url, '/api/nothing?middleware-handler=formData', null, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ hello: 'world' }).toString(), + }) + expect(await serialize(response)).toMatchObject({ + text: expect.stringContaining('ok'), + status: 200, + headers: { + 'x-req-type': 'formData', + 'x-serialized': '{"hello":"world"}', + }, + }) + }) + + it('reads a multipart form data', async () => { + const formData = new FormData() + formData.append('hello', 'world') + + const response = await fetchViaHTTP(next.url, '/api/nothing?middleware-handler=formData', null, { + method: 'POST', + body: formData, + }) + + expect(await serialize(response)).toMatchObject({ + text: expect.stringContaining('ok'), + status: 200, + headers: { + 'x-req-type': 'formData', + 'x-serialized': '{"hello":"world"}', + }, + }) + }) + }) +}) diff --git a/test/e2e/modified-tests/edge-can-use-wasm-files/add.wasm b/test/e2e/modified-tests/edge-can-use-wasm-files/add.wasm new file mode 100644 index 0000000000..f22496d0b6 Binary files /dev/null and b/test/e2e/modified-tests/edge-can-use-wasm-files/add.wasm differ diff --git a/test/e2e/modified-tests/edge-can-use-wasm-files/index.test.ts b/test/e2e/modified-tests/edge-can-use-wasm-files/index.test.ts new file mode 100644 index 0000000000..aa87a1ebca --- /dev/null +++ b/test/e2e/modified-tests/edge-can-use-wasm-files/index.test.ts @@ -0,0 +1,146 @@ +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { fetchViaHTTP } from 'next-test-utils' +import path from 'path' +import fs from 'fs-extra' + +function extractJSON(response) { + return JSON.parse(response.headers.get('data') ?? '{}') +} + +function baseNextConfig(): Parameters[0] { + return { + files: { + 'src/add.wasm': new FileRef(path.join(__dirname, './add.wasm')), + 'src/add.js': ` + import wasm from './add.wasm?module' + const instance$ = WebAssembly.instantiate(wasm); + + export async function increment(a) { + const { instance } = await instance$; + return instance.exports.add_one(a); + } + `, + 'pages/index.js': ` + export default function () { return
Hello, world!
} + `, + 'middleware.js': ` + import { increment } from './src/add.js' + export default async function middleware(request) { + const input = Number(request.nextUrl.searchParams.get('input')) || 1; + const value = await increment(input); + return new Response(null, { headers: { data: JSON.stringify({ input, value }) } }); + } + `, + }, + } +} + +describe('edge api endpoints can use wasm files', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/api/add.js': ` + import { increment } from '../../src/add.js' + export default async (request) => { + const input = Number(request.nextUrl.searchParams.get('input')) || 1; + const value = await increment(input); + return new Response(null, { headers: { data: JSON.stringify({ input, value }) } }); + } + export const config = { runtime: 'experimental-edge' }; + `, + 'src/add.wasm': new FileRef(path.join(__dirname, './add.wasm')), + 'src/add.js': ` + import wasm from './add.wasm?module' + const instance$ = WebAssembly.instantiate(wasm); + + export async function increment(a) { + const { instance } = await instance$; + return instance.exports.add_one(a); + } + `, + }, + }) + }) + afterAll(() => next.destroy()) + it('uses the wasm file', async () => { + const response = await fetchViaHTTP(next.url, '/api/add', { input: 10 }) + expect(extractJSON(response)).toEqual({ + input: 10, + value: 11, + }) + }) +}) + +describe('middleware can use wasm files', () => { + let next: NextInstance + + beforeAll(async () => { + const config = baseNextConfig() + next = await createNext(config) + }) + afterAll(() => next.destroy()) + + it('uses the wasm file', async () => { + const response = await fetchViaHTTP(next.url, '/') + expect(extractJSON(response)).toEqual({ + input: 1, + value: 2, + }) + }) + + it('can be called twice', async () => { + const response = await fetchViaHTTP(next.url, '/', { input: 2 }) + expect(extractJSON(response)).toEqual({ + input: 2, + value: 3, + }) + }) + + if (!(global as any).isNextDeploy) { + it('lists the necessary wasm bindings in the manifest', async () => { + const manifestPath = path.join(next.testDir, '.next/server/middleware-manifest.json') + const manifest = await fs.readJSON(manifestPath) + expect(manifest.middleware['/']).toMatchObject({ + wasm: [ + { + filePath: 'server/edge-chunks/wasm_58ccff8b2b94b5dac6ef8957082ecd8f6d34186d.wasm', + name: 'wasm_58ccff8b2b94b5dac6ef8957082ecd8f6d34186d', + }, + ], + }) + }) + } +}) + +describe('middleware can use wasm files with the experimental modes on', () => { + let next: NextInstance + + beforeAll(async () => { + const config = baseNextConfig() + config.files['next.config.js'] = ` + module.exports = { + webpack(config) { + config.output.webassemblyModuleFilename = 'static/wasm/[modulehash].wasm' + + // Since Webpack 5 doesn't enable WebAssembly by default, we should do it manually + config.experiments = { ...config.experiments, asyncWebAssembly: true } + + return config + }, + } + ` + next = await createNext(config) + }) + afterAll(() => next.destroy()) + + it('uses the wasm file', async () => { + const response = await fetchViaHTTP(next.url, '/') + expect(extractJSON(response)).toEqual({ + input: 1, + value: 2, + }) + }) +}) diff --git a/test/e2e/modified-tests/edge-compiler-can-import-blob-assets/app/node_modules/my-pkg/hello/world.json b/test/e2e/modified-tests/edge-compiler-can-import-blob-assets/app/node_modules/my-pkg/hello/world.json new file mode 100644 index 0000000000..394d6c8bc1 --- /dev/null +++ b/test/e2e/modified-tests/edge-compiler-can-import-blob-assets/app/node_modules/my-pkg/hello/world.json @@ -0,0 +1 @@ +{ "i am": "a node dependency" } diff --git a/test/e2e/modified-tests/edge-compiler-can-import-blob-assets/app/pages/api/edge.js b/test/e2e/modified-tests/edge-compiler-can-import-blob-assets/app/pages/api/edge.js new file mode 100644 index 0000000000..32264784d7 --- /dev/null +++ b/test/e2e/modified-tests/edge-compiler-can-import-blob-assets/app/pages/api/edge.js @@ -0,0 +1,53 @@ +export const config = { runtime: 'experimental-edge' } + +/** + * @param {import('next/server').NextRequest} req + */ +export default async (req) => { + const handlerName = req.nextUrl.searchParams.get('handler') + const handler = handlers.get(handlerName) || defaultHandler + return handler() +} + +/** + * @type {Map Promise>} + */ +const handlers = new Map([ + [ + 'text-file', + async () => { + const url = new URL('../../src/text-file.txt', import.meta.url) + return fetch(url) + }, + ], + [ + 'image-file', + async () => { + const url = new URL('../../src/vercel.png', import.meta.url) + return fetch(url) + }, + ], + // [ + // 'from-node-module', + // async () => { + // const url = new URL('my-pkg/hello/world.json', import.meta.url) + // return fetch(url) + // }, + // ], + [ + 'remote-full', + async () => { + const url = new URL('https://example.vercel.sh') + return fetch(url) + }, + ], + [ + 'remote-with-base', + async () => { + const url = new URL('/', 'https://example.vercel.sh') + return fetch(url) + }, + ], +]) + +const defaultHandler = async () => new Response('Invalid handler', { status: 400 }) diff --git a/test/e2e/modified-tests/edge-compiler-can-import-blob-assets/app/src/text-file.txt b/test/e2e/modified-tests/edge-compiler-can-import-blob-assets/app/src/text-file.txt new file mode 100644 index 0000000000..37607fa1b1 --- /dev/null +++ b/test/e2e/modified-tests/edge-compiler-can-import-blob-assets/app/src/text-file.txt @@ -0,0 +1 @@ +Hello, from text-file.txt! diff --git a/test/e2e/modified-tests/edge-compiler-can-import-blob-assets/app/src/vercel.png b/test/e2e/modified-tests/edge-compiler-can-import-blob-assets/app/src/vercel.png new file mode 100644 index 0000000000..cb137a989e Binary files /dev/null and b/test/e2e/modified-tests/edge-compiler-can-import-blob-assets/app/src/vercel.png differ diff --git a/test/e2e/modified-tests/edge-compiler-can-import-blob-assets/index.test.ts b/test/e2e/modified-tests/edge-compiler-can-import-blob-assets/index.test.ts new file mode 100644 index 0000000000..bd59e1688c --- /dev/null +++ b/test/e2e/modified-tests/edge-compiler-can-import-blob-assets/index.test.ts @@ -0,0 +1,91 @@ +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { fetchViaHTTP, renderViaHTTP } from 'next-test-utils' +import path from 'path' +import { promises as fs } from 'fs' +import { readJson } from 'fs-extra' +import type { MiddlewareManifest } from 'next/build/webpack/plugins/middleware-plugin' + +describe('Edge Compiler can import asset assets', () => { + let next: NextInstance + + // TODO: remove after this is supported for deploy + // if ((global as any).isNextDeploy) { + // it('should skip for deploy for now', () => {}) + // return + // } + + beforeAll(async () => { + next = await createNext({ + files: new FileRef(path.join(__dirname, './app')), + }) + }) + afterAll(() => next.destroy()) + it('allows to fetch a remote URL', async () => { + const response = await fetchViaHTTP(next.url, '/api/edge', { + handler: 'remote-full', + }) + expect(await response.text()).toContain('Example Domain') + }) + it('allows to fetch a remote URL with a path and basename', async () => { + const response = await fetchViaHTTP( + next.url, + '/api/edge', + { + handler: 'remote-with-base', + }, + { + compress: true, + }, + ) + expect(await response.text()).toContain('Example Domain') + }) + + it('allows to fetch text assets', async () => { + const html = await renderViaHTTP(next.url, '/api/edge', { + handler: 'text-file', + }) + expect(html).toContain('Hello, from text-file.txt!') + }) + it('allows to fetch image assets', async () => { + const response = await fetchViaHTTP(next.url, '/api/edge', { + handler: 'image-file', + }) + const buffer: Buffer = await response.buffer() + const image = await fs.readFile(path.join(__dirname, './app/src/vercel.png')) + expect(buffer.equals(image)).toBeTruthy() + }) + + // it('allows to assets from node_modules', async () => { + // const response = await fetchViaHTTP(next.url, '/api/edge', { + // handler: 'from-node-module', + // }) + // const json = await response.json() + // expect(json).toEqual({ + // 'i am': 'a node dependency', + // }) + // }) + + it('extracts all the assets from the bundle', async () => { + const manifestPath = path.join(next.testDir, '.next/server/middleware-manifest.json') + const manifest: MiddlewareManifest = await readJson(manifestPath) + const orderedAssets = manifest.functions['/api/edge'].assets.sort((a, z) => { + return String(a.name).localeCompare(z.name) + }) + + expect(orderedAssets).toMatchObject([ + { + name: expect.stringMatching(/^text-file\.[0-9a-f]{16}\.txt$/), + filePath: expect.stringMatching(/^server\/edge-chunks\/asset_text-file/), + }, + { + name: expect.stringMatching(/^vercel\.[0-9a-f]{16}\.png$/), + filePath: expect.stringMatching(/^server\/edge-chunks\/asset_vercel/), + }, + // { + // name: expect.stringMatching(/^world\.[0-9a-f]{16}\.json/), + // filePath: expect.stringMatching(/^server\/edge-chunks\/asset_world/), + // }, + ]) + }) +}) diff --git a/test/e2e/modified-tests/i18n-ignore-rewrite-source-locale/rewrites-with-basepath.test.ts b/test/e2e/modified-tests/i18n-ignore-rewrite-source-locale/rewrites-with-basepath.test.ts new file mode 100644 index 0000000000..9a88d23d34 --- /dev/null +++ b/test/e2e/modified-tests/i18n-ignore-rewrite-source-locale/rewrites-with-basepath.test.ts @@ -0,0 +1,76 @@ +import { createNext } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { fetchViaHTTP, renderViaHTTP } from 'next-test-utils' +import path from 'path' +import fs from 'fs-extra' + +const locales = ['', '/en', '/sv', '/nl'] + +describe('i18n-ignore-rewrite-source-locale with basepath', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/api/hello.js': ` + export default function handler(req, res) { + res.send('hello from api') + }`, + 'public/file.txt': 'hello from file.txt', + }, + dependencies: {}, + nextConfig: { + basePath: '/basepath', + i18n: { + locales: ['en', 'sv', 'nl'], + defaultLocale: 'en', + }, + async rewrites() { + return { + beforeFiles: [ + { + source: '/:locale/rewrite-files/:path*', + destination: '/:path*', + locale: false, + }, + { + source: '/:locale/rewrite-api/:path*', + destination: '/api/:path*', + locale: false, + }, + ], + afterFiles: [], + fallback: [], + } + }, + }, + }) + }) + afterAll(() => next.destroy()) + // NTL Fail + test.skip.each(locales)('get public file by skipping locale in rewrite, locale: %s', async (locale) => { + const res = await renderViaHTTP(next.url, `/basepath${locale}/rewrite-files/file.txt`) + expect(res).toContain('hello from file.txt') + }) + + test.each(locales)('call api by skipping locale in rewrite, locale: %s', async (locale) => { + const res = await renderViaHTTP(next.url, `/basepath${locale}/rewrite-api/hello`) + expect(res).toContain('hello from api') + }) + + // build artifacts aren't available on deploy + if (!(global as any).isNextDeploy) { + test.each(locales)('get _next/static/ files by skipping locale in rewrite, locale: %s', async (locale) => { + const chunks = (await fs.readdir(path.join(next.testDir, '.next', 'static', 'chunks'))).filter((f) => + f.endsWith('.js'), + ) + + await Promise.all( + chunks.map(async (file) => { + const res = await fetchViaHTTP(next.url, `/basepath${locale}/rewrite-files/_next/static/chunks/${file}`) + expect(res.status).toBe(200) + }), + ) + }) + } +}) diff --git a/test/e2e/modified-tests/i18n-ignore-rewrite-source-locale/rewrites.test.ts b/test/e2e/modified-tests/i18n-ignore-rewrite-source-locale/rewrites.test.ts new file mode 100644 index 0000000000..86dfa7b617 --- /dev/null +++ b/test/e2e/modified-tests/i18n-ignore-rewrite-source-locale/rewrites.test.ts @@ -0,0 +1,75 @@ +import { createNext } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { fetchViaHTTP, renderViaHTTP } from 'next-test-utils' +import path from 'path' +import fs from 'fs-extra' + +const locales = ['', '/en', '/sv', '/nl'] + +describe('i18n-ignore-rewrite-source-locale', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/api/hello.js': ` + export default function handler(req, res) { + res.send('hello from api') + }`, + 'public/file.txt': 'hello from file.txt', + }, + dependencies: {}, + nextConfig: { + i18n: { + locales: ['en', 'sv', 'nl'], + defaultLocale: 'en', + }, + async rewrites() { + return { + beforeFiles: [ + { + source: '/:locale/rewrite-files/:path*', + destination: '/:path*', + locale: false, + }, + { + source: '/:locale/rewrite-api/:path*', + destination: '/api/:path*', + locale: false, + }, + ], + afterFiles: [], + fallback: [], + } + }, + }, + }) + }) + afterAll(() => next.destroy()) + // NTL Fail + test.skip.each(locales)('get public file by skipping locale in rewrite, locale: %s', async (locale) => { + const res = await renderViaHTTP(next.url, `${locale}/rewrite-files/file.txt`) + expect(res).toContain('hello from file.txt') + }) + + test.each(locales)('call api by skipping locale in rewrite, locale: %s', async (locale) => { + const res = await renderViaHTTP(next.url, `${locale}/rewrite-api/hello`) + expect(res).toContain('hello from api') + }) + + // build artifacts aren't available on deploy + if (!(global as any).isNextDeploy) { + test.each(locales)('get _next/static/ files by skipping locale in rewrite, locale: %s', async (locale) => { + const chunks = (await fs.readdir(path.join(next.testDir, '.next', 'static', 'chunks'))).filter((f) => + f.endsWith('.js'), + ) + + await Promise.all( + chunks.map(async (file) => { + const res = await fetchViaHTTP(next.url, `${locale}/rewrite-files/_next/static/chunks/${file}`) + expect(res.status).toBe(200) + }), + ) + }) + } +}) diff --git a/test/e2e/modified-tests/middleware-custom-matchers/app/middleware.js b/test/e2e/modified-tests/middleware-custom-matchers/app/middleware.js new file mode 100644 index 0000000000..99ec191d35 --- /dev/null +++ b/test/e2e/modified-tests/middleware-custom-matchers/app/middleware.js @@ -0,0 +1,81 @@ +import { NextResponse } from 'next/server' + +export default function middleware(request) { + const res = NextResponse.rewrite(new URL('/', request.url)) + res.headers.set('X-From-Middleware', 'true') + return res +} + +export const config = { + matcher: [ + { source: '/source-match' }, + { + source: '/has-match-1', + has: [ + { + type: 'header', + key: 'x-my-header', + value: '(?.*)', + }, + ], + }, + { + source: '/has-match-2', + has: [ + { + type: 'query', + key: 'my-query', + }, + ], + }, + { + source: '/has-match-3', + has: [ + { + type: 'cookie', + key: 'loggedIn', + value: '(?true)', + }, + ], + }, + { + source: '/has-match-4', + has: [ + { + type: 'host', + value: 'example.com', + }, + ], + }, + { + source: '/has-match-5', + has: [ + { + type: 'header', + key: 'hasParam', + value: 'with-params', + }, + ], + }, + { + source: '/missing-match-1', + missing: [ + { + type: 'header', + key: 'hello', + value: '(.*)', + }, + ], + }, + { + source: '/missing-match-2', + missing: [ + { + type: 'query', + key: 'test', + value: 'value', + }, + ], + }, + ], +} diff --git a/test/e2e/modified-tests/middleware-custom-matchers/app/pages/index.js b/test/e2e/modified-tests/middleware-custom-matchers/app/pages/index.js new file mode 100644 index 0000000000..accd5b17ad --- /dev/null +++ b/test/e2e/modified-tests/middleware-custom-matchers/app/pages/index.js @@ -0,0 +1,14 @@ +export default (props) => ( + <> +

home

+
{props.fromMiddleware}
+ +) + +export async function getServerSideProps({ res }) { + return { + props: { + fromMiddleware: res.getHeader('x-from-middleware') || null, + }, + } +} diff --git a/test/e2e/modified-tests/middleware-custom-matchers/app/pages/routes.js b/test/e2e/modified-tests/middleware-custom-matchers/app/pages/routes.js new file mode 100644 index 0000000000..9cf902de88 --- /dev/null +++ b/test/e2e/modified-tests/middleware-custom-matchers/app/pages/routes.js @@ -0,0 +1,16 @@ +import Link from 'next/link' + +export default (props) => ( +
    +
  • + + has-match-2 + +
  • +
  • + + has-match-3 + +
  • +
+) diff --git a/test/e2e/modified-tests/middleware-custom-matchers/test/index.test.ts b/test/e2e/modified-tests/middleware-custom-matchers/test/index.test.ts new file mode 100644 index 0000000000..c35aee9c8d --- /dev/null +++ b/test/e2e/modified-tests/middleware-custom-matchers/test/index.test.ts @@ -0,0 +1,157 @@ +/* eslint-env jest */ +/* eslint-disable jest/no-standalone-expect */ +import { join } from 'path' +import webdriver from 'next-webdriver' +import { fetchViaHTTP } from 'next-test-utils' +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +const usuallySkip = process.env.RUN_SKIPPED_TESTS ? it : it.skip +const itif = (condition: boolean) => (condition ? it : it.skip) + +const isModeDeploy = process.env.NEXT_TEST_MODE === 'deploy' + +describe('Middleware custom matchers', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: new FileRef(join(__dirname, '../app')), + }) + }) + afterAll(() => next.destroy()) + + const runTests = () => { + usuallySkip('should match missing header correctly', async () => { + const res = await fetchViaHTTP(next.url, '/missing-match-1') + expect(res.headers.get('x-from-middleware')).toBeDefined() + + const res2 = await fetchViaHTTP(next.url, '/missing-match-1', undefined, { + headers: { + hello: 'world', + }, + }) + expect(res2.headers.get('x-from-middleware')).toBeFalsy() + }) + + usuallySkip('should match missing query correctly', async () => { + const res = await fetchViaHTTP(next.url, '/missing-match-2') + expect(res.headers.get('x-from-middleware')).toBeDefined() + + const res2 = await fetchViaHTTP(next.url, '/missing-match-2', { + test: 'value', + }) + expect(res2.headers.get('x-from-middleware')).toBeFalsy() + }) + + it('should match source path', async () => { + const res = await fetchViaHTTP(next.url, '/source-match') + expect(res.status).toBe(200) + expect(res.headers.get('x-from-middleware')).toBeDefined() + }) + + it('should match has header', async () => { + const res = await fetchViaHTTP(next.url, '/has-match-1', undefined, { + headers: { + 'x-my-header': 'hello world!!', + }, + }) + expect(res.status).toBe(200) + expect(res.headers.get('x-from-middleware')).toBeDefined() + + const res2 = await fetchViaHTTP(next.url, '/has-match-1') + expect(res2.status).toBe(404) + }) + + it('should match has query', async () => { + const res = await fetchViaHTTP(next.url, '/has-match-2', { + 'my-query': 'hellooo', + }) + expect(res.status).toBe(200) + expect(res.headers.get('x-from-middleware')).toBeDefined() + + const res2 = await fetchViaHTTP(next.url, '/has-match-2') + expect(res2.status).toBe(404) + }) + + it('should match has cookie', async () => { + const res = await fetchViaHTTP(next.url, '/has-match-3', undefined, { + headers: { + cookie: 'loggedIn=true', + }, + }) + expect(res.status).toBe(200) + expect(res.headers.get('x-from-middleware')).toBeDefined() + + const res2 = await fetchViaHTTP(next.url, '/has-match-3', undefined, { + headers: { + cookie: 'loggedIn=false', + }, + }) + expect(res2.status).toBe(404) + }) + + // Cannot modify host when testing with real deployment + itif(!isModeDeploy)('should match has host', async () => { + const res1 = await fetchViaHTTP(next.url, '/has-match-4') + expect(res1.status).toBe(404) + + const res = await fetchViaHTTP(next.url, '/has-match-4', undefined, { + headers: { + host: 'example.com', + }, + }) + + expect(res.status).toBe(200) + expect(res.headers.get('x-from-middleware')).toBeDefined() + + const res2 = await fetchViaHTTP(next.url, '/has-match-4', undefined, { + headers: { + host: 'example.org', + }, + }) + expect(res2.status).toBe(404) + }) + + it('should match has header value', async () => { + const res = await fetchViaHTTP(next.url, '/has-match-5', undefined, { + headers: { + hasParam: 'with-params', + }, + }) + expect(res.status).toBe(200) + expect(res.headers.get('x-from-middleware')).toBeDefined() + + const res2 = await fetchViaHTTP(next.url, '/has-match-5', undefined, { + headers: { + hasParam: 'without-params', + }, + }) + expect(res2.status).toBe(404) + }) + + // FIXME: Test fails on Vercel deployment for now. + // See https://linear.app/vercel/issue/EC-160/header-value-set-on-middleware-is-not-propagated-on-client-request-of + itif(!isModeDeploy)('should match has query on client routing', async () => { + const browser = await webdriver(next.url, '/routes') + await browser.eval('window.__TEST_NO_RELOAD = true') + await browser.elementById('has-match-2').click() + const fromMiddleware = await browser.elementById('from-middleware').text() + expect(fromMiddleware).toBe('true') + const noReload = await browser.eval('window.__TEST_NO_RELOAD') + expect(noReload).toBe(true) + }) + + itif(!isModeDeploy)('should match has cookie on client routing', async () => { + const browser = await webdriver(next.url, '/routes') + await browser.addCookie({ name: 'loggedIn', value: 'true' }) + await browser.refresh() + await browser.eval('window.__TEST_NO_RELOAD = true') + await browser.elementById('has-match-3').click() + const fromMiddleware = await browser.elementById('from-middleware').text() + expect(fromMiddleware).toBe('true') + const noReload = await browser.eval('window.__TEST_NO_RELOAD') + expect(noReload).toBe(true) + }) + } + runTests() +}) diff --git a/test/e2e/modified-tests/middleware-fetches-with-body/index.test.ts b/test/e2e/modified-tests/middleware-fetches-with-body/index.test.ts new file mode 100644 index 0000000000..206ffc3e90 --- /dev/null +++ b/test/e2e/modified-tests/middleware-fetches-with-body/index.test.ts @@ -0,0 +1,268 @@ +import { createNext } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { fetchViaHTTP } from 'next-test-utils' +const usuallySkip = process.env.RUN_SKIPPED_TESTS ? it : it.skip + +describe('Middleware fetches with body', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/api/default.js': ` + export default (req, res) => res.json({ body: req.body }) + `, + 'pages/api/size_limit_5kb.js': ` + export const config = { api: { bodyParser: { sizeLimit: '5kb' } } } + export default (req, res) => res.json({ body: req.body }) + `, + 'pages/api/size_limit_5mb.js': ` + export const config = { api: { bodyParser: { sizeLimit: '5mb' } } } + export default (req, res) => res.json({ body: req.body }) + `, + 'pages/api/body_parser_false.js': ` + export const config = { api: { bodyParser: false } } + + async function buffer(readable) { + const chunks = [] + for await (const chunk of readable) { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk) + } + return Buffer.concat(chunks) + } + + export default async (req, res) => { + const buf = await buffer(req) + const rawBody = buf.toString('utf8'); + + res.json({ rawBody, body: req.body }) + } + `, + 'middleware.js': ` + import { NextResponse } from 'next/server'; + + export default async (req) => NextResponse.next(); + `, + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + describe('with default bodyParser sizeLimit (1mb)', () => { + it('should return 413 for body greater than 1mb', async () => { + const bodySize = 1024 * 1024 + 1 + const body = 'r'.repeat(bodySize) + + const res = await fetchViaHTTP( + next.url, + '/api/default', + {}, + { + body, + method: 'POST', + }, + ) + + expect(res.status).toBe(413) + + if (!(global as any).isNextDeploy) { + expect(res.statusText).toBe('Body exceeded 1mb limit') + } + }) + + it('should be able to send and return body size equal to 1mb', async () => { + const bodySize = 1024 * 1024 + const body = 'B1C2D3E4F5G6H7I8J9K0LaMbNcOdPeQf'.repeat(bodySize / 32) + + const res = await fetchViaHTTP( + next.url, + '/api/default', + {}, + { + body, + method: 'POST', + }, + ) + const data = await res.json() + + expect(res.status).toBe(200) + expect(data.body.length).toBe(bodySize) + expect(data.body.split('B1C2D3E4F5G6H7I8J9K0LaMbNcOdPeQf').length).toBe(bodySize / 32 + 1) + }) + + it('should be able to send and return body greater than default highWaterMark (16KiB)', async () => { + const bodySize = 16 * 1024 + 1 + const body = 'CD1E2F3G4H5I6J7K8L9M0NaObPcQdReS'.repeat(bodySize / 32) + 'C' + + const res = await fetchViaHTTP( + next.url, + '/api/default', + {}, + { + body, + method: 'POST', + }, + ) + const data = await res.json() + + expect(res.status).toBe(200) + expect(data.body.length).toBe(bodySize) + expect(data.body.split('CD1E2F3G4H5I6J7K8L9M0NaObPcQdReS').length).toBe(512 + 1) + }) + }) + + describe('with custom bodyParser sizeLimit (5kb)', () => { + it('should return 413 for body greater than 5kb', async () => { + const bodySize = 5 * 1024 + 1 + const body = 's'.repeat(bodySize) + + const res = await fetchViaHTTP( + next.url, + '/api/size_limit_5kb', + {}, + { + body, + method: 'POST', + }, + ) + + expect(res.status).toBe(413) + + if (!(global as any).isNextDeploy) { + expect(res.statusText).toBe('Body exceeded 5kb limit') + } + }) + + it('should be able to send and return body size equal to 5kb', async () => { + const bodySize = 5120 + const body = 'DEF1G2H3I4J5K6L7M8N9O0PaQbRcSdTe'.repeat(bodySize / 32) + + const res = await fetchViaHTTP( + next.url, + '/api/size_limit_5kb', + {}, + { + body, + method: 'POST', + }, + ) + const data = await res.json() + + expect(res.status).toBe(200) + expect(data.body.length).toBe(bodySize) + expect(data.body.split('DEF1G2H3I4J5K6L7M8N9O0PaQbRcSdTe').length).toBe(bodySize / 32 + 1) + }) + }) + + describe('with custom bodyParser sizeLimit (5mb)', () => { + // NTL: disabled because of EF bug + usuallySkip('should return 413 for body equal to 10mb', async () => { + const bodySize = 10 * 1024 * 1024 + const body = 't'.repeat(bodySize) + + const res = await fetchViaHTTP( + next.url, + '/api/size_limit_5mb', + {}, + { + body, + method: 'POST', + }, + ) + + expect(res.status).toBe(413) + + if (!(global as any).isNextDeploy) { + expect(res.statusText).toBe('Body exceeded 5mb limit') + } + }) + + it('should return 413 for body greater than 5mb', async () => { + const bodySize = 5 * 1024 * 1024 + 1 + const body = 'u'.repeat(bodySize) + + const res = await fetchViaHTTP( + next.url, + '/api/size_limit_5mb', + {}, + { + body, + method: 'POST', + }, + ) + + expect(res.status).toBe(413) + + if (!(global as any).isNextDeploy) { + expect(res.statusText).toBe('Body exceeded 5mb limit') + } + }) + + if (!(global as any).isNextDeploy) { + it('should be able to send and return body size equal to 5mb', async () => { + const bodySize = 5 * 1024 * 1024 + const body = 'FGHI1J2K3L4M5N6O7P8Q9R0SaTbUcVdW'.repeat(bodySize / 32) + + const res = await fetchViaHTTP( + next.url, + '/api/size_limit_5mb', + {}, + { + body, + method: 'POST', + }, + ) + const data = await res.json() + + expect(res.status).toBe(200) + expect(data.body.length).toBe(bodySize) + expect(data.body.split('FGHI1J2K3L4M5N6O7P8Q9R0SaTbUcVdW').length).toBe(bodySize / 32 + 1) + }) + } + }) + + describe('with bodyParser = false', () => { + it('should be able to send and return with body size equal to 16KiB', async () => { + const bodySize = 16 * 1024 + const body = 'HIJK1L2M3N4O5P6Q7R8S9T0UaVbWcXdY'.repeat(bodySize / 32) + + const res = await fetchViaHTTP( + next.url, + '/api/body_parser_false', + {}, + { + body, + method: 'POST', + }, + ) + const data = await res.json() + + expect(res.status).toBe(200) + expect(data.body).toBeUndefined() + expect(data.rawBody.length).toBe(bodySize) + expect(data.rawBody.split('HIJK1L2M3N4O5P6Q7R8S9T0UaVbWcXdY').length).toBe(bodySize / 32 + 1) + }) + + it('should be able to send and return with body greater than 16KiB', async () => { + const bodySize = 1024 * 1024 + const body = 'JKLM1N2O3P4Q5R6S7T8U9V0WaXbYcZdA'.repeat(bodySize / 32) + + const res = await fetchViaHTTP( + next.url, + '/api/body_parser_false', + {}, + { + body, + method: 'POST', + }, + ) + const data = await res.json() + + expect(res.status).toBe(200) + expect(data.body).toBeUndefined() + expect(data.rawBody.length).toBe(bodySize) + expect(data.rawBody.split('JKLM1N2O3P4Q5R6S7T8U9V0WaXbYcZdA').length).toBe(bodySize / 32 + 1) + }) + }) +}) diff --git a/test/e2e/modified-tests/middleware-general/app/middleware.js b/test/e2e/modified-tests/middleware-general/app/middleware.js new file mode 100644 index 0000000000..54e5b196af --- /dev/null +++ b/test/e2e/modified-tests/middleware-general/app/middleware.js @@ -0,0 +1,273 @@ +/* global globalThis */ +import { NextRequest, NextResponse, URLPattern } from 'next/server' +import magicValue from 'shared-package' + +export const config = { regions: 'auto' } + +const PATTERNS = [ + [ + new URLPattern({ pathname: '/:locale/:id' }), + ({ pathname }) => ({ + pathname: '/:locale/:id', + params: pathname.groups, + }), + ], + [ + new URLPattern({ pathname: '/:id' }), + ({ pathname }) => ({ + pathname: '/:id', + params: pathname.groups, + }), + ], +] + +const params = (url) => { + const input = url.split('?')[0] + let result = {} + + for (const [pattern, handler] of PATTERNS) { + const patternResult = pattern.exec(input) + if (patternResult !== null && 'pathname' in patternResult) { + result = handler(patternResult) + break + } + } + return result +} + +export async function middleware(request) { + const url = request.nextUrl + + if (request.headers.get('x-prerender-revalidate')) { + return NextResponse.next({ + headers: { 'x-middleware': 'hi' }, + }) + } + + // this is needed for tests to get the BUILD_ID + if (url.pathname.startsWith('/_next/static/__BUILD_ID')) { + return NextResponse.next() + } + + if (url.pathname === '/api/edge-search-params') { + const newUrl = url.clone() + newUrl.searchParams.set('foo', 'bar') + return NextResponse.rewrite(newUrl) + } + + if (url.pathname === '/') { + url.pathname = '/ssg/first' + return NextResponse.rewrite(url) + } + + if (url.pathname === '/to-ssg') { + url.pathname = '/ssg/hello' + url.searchParams.set('from', 'middleware') + return NextResponse.rewrite(url) + } + + if (url.pathname === '/sha') { + url.pathname = '/shallow' + return NextResponse.rewrite(url) + } + + if (url.pathname.startsWith('/fetch-user-agent-default')) { + try { + const apiRoute = new URL(url) + apiRoute.pathname = '/api/headers' + const res = await fetch(withLocalIp(apiRoute)) + return serializeData(await res.text()) + } catch (err) { + return serializeError(err) + } + } + + if (url.pathname === '/rewrite-to-dynamic') { + url.pathname = '/blog/from-middleware' + url.searchParams.set('some', 'middleware') + return NextResponse.rewrite(url) + } + + if (url.pathname === '/rewrite-to-config-rewrite') { + url.pathname = '/rewrite-3' + url.searchParams.set('some', 'middleware') + return NextResponse.rewrite(url) + } + + if (url.pathname.startsWith('/fetch-user-agent-crypto')) { + try { + const apiRoute = new URL(url) + apiRoute.pathname = '/api/headers' + const res = await fetch(withLocalIp(apiRoute), { + headers: { + 'user-agent': 'custom-agent', + }, + }) + return serializeData(await res.text()) + } catch (err) { + return serializeError(err) + } + } + + if (url.pathname === '/global') { + // The next line is required to allow to find the env variable + // eslint-disable-next-line no-unused-expressions + process.env.MIDDLEWARE_TEST + + // The next line is required to allow to find the env variable + // eslint-disable-next-line no-unused-expressions + const { ANOTHER_MIDDLEWARE_TEST } = process.env + if (!ANOTHER_MIDDLEWARE_TEST) { + console.log('missing ANOTHER_MIDDLEWARE_TEST') + } + + const { STRING_ENV_VAR: stringEnvVar } = process['env'] + if (!stringEnvVar) { + console.log('missing STRING_ENV_VAR') + } + + return serializeData(JSON.stringify({ process: { env: process.env } })) + } + + if (url.pathname.endsWith('/globalthis')) { + return serializeData(JSON.stringify(Object.keys(globalThis))) + } + + if (url.pathname.endsWith('/webcrypto')) { + const response = {} + try { + const algorithm = { + name: 'RSA-PSS', + hash: 'SHA-256', + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + modulusLength: 2048, + } + const keyUsages = ['sign', 'verify'] + await crypto.subtle.generateKey(algorithm, false, keyUsages) + } catch (err) { + response.error = true + } finally { + return serializeData(JSON.stringify(response)) + } + } + + if (url.pathname.endsWith('/fetch-url')) { + const response = {} + try { + await fetch(new URL('http://localhost')) + } catch (err) { + response.error = { + name: err.name, + message: err.message, + } + } finally { + return serializeData(JSON.stringify(response)) + } + } + + if (url.pathname === '/abort-controller') { + const controller = new AbortController() + const signal = controller.signal + + controller.abort() + const response = {} + + try { + await fetch('https://example.vercel.sh', { signal }) + } catch (err) { + response.error = { + name: err.name, + message: err.message, + } + } finally { + return serializeData(JSON.stringify(response)) + } + } + + if (url.pathname.endsWith('/root-subrequest')) { + const res = await fetch(url) + res.headers.set('x-dynamic-path', 'true') + return res + } + + if (url.pathname === '/about') { + if (magicValue !== 42) throw new Error('shared-package problem') + return NextResponse.rewrite(new URL('/about/a', request.url)) + } + + if (url.pathname === '/redirect-to-somewhere') { + url.pathname = '/somewhere' + return NextResponse.redirect(url, { + headers: { + 'x-redirect-header': 'hi', + }, + }) + } + + if (url.pathname.startsWith('/url')) { + try { + if (request.nextUrl.pathname === '/url/relative-url') { + new URL('/relative') + return Response.next() + } + + if (request.nextUrl.pathname === '/url/relative-request') { + await fetch(new Request('/urls-b')) + return Response.next() + } + + if (request.nextUrl.pathname === '/url/relative-redirect') { + return Response.redirect('/urls-b') + } + + if (request.nextUrl.pathname === '/url/relative-next-redirect') { + return NextResponse.redirect('/urls-b') + } + + if (request.nextUrl.pathname === '/url/relative-next-rewrite') { + return NextResponse.rewrite('/urls-b') + } + + if (request.nextUrl.pathname === '/url/relative-next-request') { + await fetch(new NextRequest('/urls-b')) + return NextResponse.next() + } + } catch (error) { + return new NextResponse(null, { headers: { error: error.message } }) + } + } + + if (url.pathname === '/ssr-page') { + url.pathname = '/ssr-page-2' + return NextResponse.rewrite(url) + } + + if (url.pathname === '/error-throw' && request.__isData) { + throw new Error('test error') + } + + const original = new URL(request.url) + return NextResponse.next({ + headers: { + 'req-url-path': `${original.pathname}${original.search}`, + 'req-url-basepath': request.nextUrl.basePath, + 'req-url-pathname': request.nextUrl.pathname, + 'req-url-query': request.nextUrl.searchParams.get('foo'), + 'req-url-locale': request.nextUrl.locale, + 'req-url-params': + url.pathname !== '/static' ? JSON.stringify(params(request.url)) : '{}', + }, + }) +} + +function serializeData(data) { + return new NextResponse(null, { headers: { data } }) +} + +function serializeError(error) { + return new NextResponse(null, { headers: { error: error.message } }) +} + +function withLocalIp(url) { + return String(url).replace('localhost', '127.0.0.1') +} diff --git a/test/e2e/modified-tests/middleware-general/app/next.config.js b/test/e2e/modified-tests/middleware-general/app/next.config.js new file mode 100644 index 0000000000..9308df9dcb --- /dev/null +++ b/test/e2e/modified-tests/middleware-general/app/next.config.js @@ -0,0 +1,35 @@ +module.exports = { + i18n: { + locales: ['en', 'fr', 'nl'], + defaultLocale: 'en', + }, + redirects() { + return [ + { + source: '/redirect-1', + destination: '/somewhere/else', + permanent: false, + }, + ] + }, + rewrites() { + return [ + { + source: '/rewrite-1', + destination: '/ssr-page?from=config', + }, + { + source: '/rewrite-2', + destination: '/about/a?from=next-config', + }, + { + source: '/sha', + destination: '/shallow', + }, + { + source: '/rewrite-3', + destination: '/blog/middleware-rewrite?hello=config', + }, + ] + }, +} diff --git a/test/e2e/modified-tests/middleware-general/app/node_modules/shared-package/index.js b/test/e2e/modified-tests/middleware-general/app/node_modules/shared-package/index.js new file mode 100644 index 0000000000..a8653a9c92 --- /dev/null +++ b/test/e2e/modified-tests/middleware-general/app/node_modules/shared-package/index.js @@ -0,0 +1 @@ +module.exports = 42 diff --git a/test/e2e/modified-tests/middleware-general/app/node_modules/shared-package/package.json b/test/e2e/modified-tests/middleware-general/app/node_modules/shared-package/package.json new file mode 100644 index 0000000000..1587a66968 --- /dev/null +++ b/test/e2e/modified-tests/middleware-general/app/node_modules/shared-package/package.json @@ -0,0 +1,3 @@ +{ + "version": "1.0.0" +} diff --git a/test/e2e/modified-tests/middleware-general/app/pages/[id].js b/test/e2e/modified-tests/middleware-general/app/pages/[id].js new file mode 100644 index 0000000000..11f4614a67 --- /dev/null +++ b/test/e2e/modified-tests/middleware-general/app/pages/[id].js @@ -0,0 +1,3 @@ +export default function Index() { + return

Dynamic route

+} diff --git a/test/e2e/modified-tests/middleware-general/app/pages/_app.js b/test/e2e/modified-tests/middleware-general/app/pages/_app.js new file mode 100644 index 0000000000..65e905445e --- /dev/null +++ b/test/e2e/modified-tests/middleware-general/app/pages/_app.js @@ -0,0 +1,8 @@ +export default function App({ Component, pageProps }) { + if (!pageProps || typeof pageProps !== 'object') { + throw new Error( + `Invariant: received invalid pageProps in _app, received ${pageProps}` + ) + } + return +} diff --git a/test/e2e/modified-tests/middleware-general/app/pages/about/a.js b/test/e2e/modified-tests/middleware-general/app/pages/about/a.js new file mode 100644 index 0000000000..dfacd4eaff --- /dev/null +++ b/test/e2e/modified-tests/middleware-general/app/pages/about/a.js @@ -0,0 +1,7 @@ +export default function AboutA() { + return ( +
+

AboutA

+
+ ) +} diff --git a/test/e2e/modified-tests/middleware-general/app/pages/about/b.js b/test/e2e/modified-tests/middleware-general/app/pages/about/b.js new file mode 100644 index 0000000000..7a44254c45 --- /dev/null +++ b/test/e2e/modified-tests/middleware-general/app/pages/about/b.js @@ -0,0 +1,7 @@ +export default function AboutB() { + return ( +
+

AboutB

+
+ ) +} diff --git a/test/e2e/modified-tests/middleware-general/app/pages/api/edge-search-params.js b/test/e2e/modified-tests/middleware-general/app/pages/api/edge-search-params.js new file mode 100644 index 0000000000..01a968ee9d --- /dev/null +++ b/test/e2e/modified-tests/middleware-general/app/pages/api/edge-search-params.js @@ -0,0 +1,10 @@ +import { NextResponse } from 'next/server' + +export const config = { runtime: 'experimental-edge', regions: 'default' } + +/** + * @param {import('next/server').NextRequest} + */ +export default (req) => { + return NextResponse.json(Object.fromEntries(req.nextUrl.searchParams)) +} diff --git a/test/e2e/modified-tests/middleware-general/app/pages/api/headers.js b/test/e2e/modified-tests/middleware-general/app/pages/api/headers.js new file mode 100644 index 0000000000..0f65c82e9f --- /dev/null +++ b/test/e2e/modified-tests/middleware-general/app/pages/api/headers.js @@ -0,0 +1,3 @@ +export default function handler(req, res) { + res.json({ url: req.url, headers: req.headers }) +} diff --git a/test/e2e/modified-tests/middleware-general/app/pages/blog/[slug].js b/test/e2e/modified-tests/middleware-general/app/pages/blog/[slug].js new file mode 100644 index 0000000000..590ca0dcc1 --- /dev/null +++ b/test/e2e/modified-tests/middleware-general/app/pages/blog/[slug].js @@ -0,0 +1,23 @@ +import { useRouter } from 'next/router' + +export default function Page(props) { + const router = useRouter() + return ( + <> +

/blog/[slug]

+

{JSON.stringify(router.query)}

+

{router.pathname}

+

{router.asPath}

+

{JSON.stringify(props)}

+ + ) +} + +export function getServerSideProps({ params }) { + return { + props: { + now: Date.now(), + params, + }, + } +} diff --git a/test/e2e/modified-tests/middleware-general/app/pages/error-throw.js b/test/e2e/modified-tests/middleware-general/app/pages/error-throw.js new file mode 100644 index 0000000000..b8a8706e5f --- /dev/null +++ b/test/e2e/modified-tests/middleware-general/app/pages/error-throw.js @@ -0,0 +1,12 @@ +export default function ThrowOnData({ message }) { + return ( +
+

Throw on data request

+

{message}

+
+ ) +} + +export const getServerSideProps = ({ query }) => ({ + props: { message: query.message || '' }, +}) diff --git a/test/e2e/modified-tests/middleware-general/app/pages/error.js b/test/e2e/modified-tests/middleware-general/app/pages/error.js new file mode 100644 index 0000000000..a5a49734cc --- /dev/null +++ b/test/e2e/modified-tests/middleware-general/app/pages/error.js @@ -0,0 +1,11 @@ +import Link from 'next/link' + +export default function Errors() { + return ( +
+ + Throw on data + +
+ ) +} diff --git a/test/e2e/modified-tests/middleware-general/app/pages/shallow.js b/test/e2e/modified-tests/middleware-general/app/pages/shallow.js new file mode 100644 index 0000000000..4d1c8564ee --- /dev/null +++ b/test/e2e/modified-tests/middleware-general/app/pages/shallow.js @@ -0,0 +1,43 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' + +export default function Shallow({ message }) { + const { pathname, query } = useRouter() + return ( +
+
    +
  • {message}
  • +
  • + + Shallow link to ?hello=world + +
  • +
  • + + Deep link to ?hello=goodbye + +
  • +
  • +

    + Current path: {pathname} +

    +
  • +
  • +

    + Current query: {JSON.stringify(query)} +

    +
  • +
+
+ ) +} + +let i = 0 + +export const getServerSideProps = () => { + return { + props: { + message: `Random: ${++i}${Math.random()}`, + }, + } +} diff --git a/test/e2e/modified-tests/middleware-general/app/pages/ssg/[slug].js b/test/e2e/modified-tests/middleware-general/app/pages/ssg/[slug].js new file mode 100644 index 0000000000..cf9fb1db0b --- /dev/null +++ b/test/e2e/modified-tests/middleware-general/app/pages/ssg/[slug].js @@ -0,0 +1,42 @@ +import { useRouter } from 'next/router' +import { useEffect } from 'react' +import { useState } from 'react' + +export default function Page(props) { + const router = useRouter() + const [asPath, setAsPath] = useState( + router.isReady ? router.asPath : router.href + ) + + useEffect(() => { + if (router.isReady) { + setAsPath(router.asPath) + } + }, [router.asPath, router.isReady]) + + return ( + <> +

/blog/[slug]

+

{JSON.stringify(router.query)}

+

{router.pathname}

+

{asPath}

+

{JSON.stringify(props)}

+ + ) +} + +export function getStaticProps({ params }) { + return { + props: { + now: Date.now(), + params, + }, + } +} + +export function getStaticPaths() { + return { + paths: ['/ssg/first', '/ssg/hello'], + fallback: 'blocking', + } +} diff --git a/test/e2e/modified-tests/middleware-general/app/pages/ssr-page-2.js b/test/e2e/modified-tests/middleware-general/app/pages/ssr-page-2.js new file mode 100644 index 0000000000..67e1b70868 --- /dev/null +++ b/test/e2e/modified-tests/middleware-general/app/pages/ssr-page-2.js @@ -0,0 +1,11 @@ +export default function SSRPage(props) { + return

{props.message}

+} + +export const getServerSideProps = (req) => { + return { + props: { + message: 'Bye Cruel World', + }, + } +} diff --git a/test/e2e/modified-tests/middleware-general/app/pages/ssr-page.js b/test/e2e/modified-tests/middleware-general/app/pages/ssr-page.js new file mode 100644 index 0000000000..e2aaaa56b4 --- /dev/null +++ b/test/e2e/modified-tests/middleware-general/app/pages/ssr-page.js @@ -0,0 +1,11 @@ +export default function SSRPage(props) { + return

{props.message}

+} + +export const getServerSideProps = (req) => { + return { + props: { + message: 'Hello World', + }, + } +} diff --git a/test/e2e/modified-tests/middleware-general/test/index.test.ts b/test/e2e/modified-tests/middleware-general/test/index.test.ts new file mode 100644 index 0000000000..56af39e645 --- /dev/null +++ b/test/e2e/modified-tests/middleware-general/test/index.test.ts @@ -0,0 +1,566 @@ +/* eslint-env jest */ + +import fs from 'fs-extra' +import { join } from 'path' +import webdriver from 'next-webdriver' +import { NextInstance } from 'test/lib/next-modes/base' +import { check, fetchViaHTTP, waitFor } from 'next-test-utils' +import { createNext, FileRef } from 'e2e-utils' + +const urlsError = 'Please use only absolute URLs' +const usuallySkip = process.env.RUN_SKIPPED_TESTS ? it : it.skip + +describe('Middleware Runtime', () => { + let next: NextInstance + + const setup = ({ i18n }: { i18n: boolean }) => { + let nextConfigContent = '' + const nextConfigPath = join(__dirname, '../app/next.config.js') + + afterAll(async () => { + await next.destroy() + + if (nextConfigContent) { + await fs.writeFile(nextConfigPath, nextConfigContent) + } + }) + beforeAll(async () => { + if (!i18n) { + nextConfigContent = await fs.readFile(nextConfigPath, 'utf8') + await fs.writeFile(nextConfigPath, nextConfigContent.replace('i18n', '__i18n')) + } + next = await createNext({ + files: { + 'next.config.js': new FileRef(join(__dirname, '../app/next.config.js')), + 'middleware.js': new FileRef(join(__dirname, '../app/middleware.js')), + pages: new FileRef(join(__dirname, '../app/pages')), + 'shared-package': new FileRef(join(__dirname, '../app/node_modules/shared-package')), + }, + packageJson: { + scripts: { + setup: `cp -r ./shared-package ./node_modules`, + build: 'yarn setup && next build', + dev: 'yarn setup && next dev', + start: 'next start', + }, + }, + startCommand: (global as any).isNextDev ? 'yarn dev' : 'yarn start', + buildCommand: 'yarn build', + env: { + ANOTHER_MIDDLEWARE_TEST: 'asdf2', + STRING_ENV_VAR: 'asdf3', + MIDDLEWARE_TEST: 'asdf', + }, + }) + }) + } + + function readMiddlewareJSON(response) { + return JSON.parse(response.headers.get('data')) + } + + function readMiddlewareError(response) { + return response.headers.get('error') + } + + function runTests({ i18n }: { i18n?: boolean }) { + if ((global as any).isNextDev) { + it('refreshes the page when middleware changes ', async () => { + const browser = await webdriver(next.url, `/about`) + await browser.eval('window.didrefresh = "hello"') + const text = await browser.elementByCss('h1').text() + expect(text).toEqual('AboutA') + + const middlewarePath = join(next.testDir, '/middleware.js') + const originalContent = fs.readFileSync(middlewarePath, 'utf-8') + const editedContent = originalContent.replace('/about/a', '/about/b') + + try { + fs.writeFileSync(middlewarePath, editedContent) + await waitFor(1000) + const textb = await browser.elementByCss('h1').text() + expect(await browser.eval('window.itdidnotrefresh')).not.toBe('hello') + expect(textb).toEqual('AboutB') + } finally { + fs.writeFileSync(middlewarePath, originalContent) + await browser.close() + } + }) + + it('should only contain middleware route in dev middleware manifest', async () => { + const res = await fetchViaHTTP(next.url, `/_next/static/${next.buildId}/_devMiddlewareManifest.json`) + const matchers = await res.json() + expect(matchers).toEqual([{ regexp: '.*' }]) + }) + } + + if ((global as any).isNextStart) { + it('should have valid middleware field in manifest', async () => { + const manifest = await fs.readJSON(join(next.testDir, '.next/server/middleware-manifest.json')) + expect(manifest.middleware).toEqual({ + '/': { + env: ['MIDDLEWARE_TEST', 'ANOTHER_MIDDLEWARE_TEST', 'STRING_ENV_VAR'], + files: expect.arrayContaining(['server/edge-runtime-webpack.js', 'server/middleware.js']), + name: 'middleware', + page: '/', + matchers: [{ regexp: '^/.*$' }], + wasm: [], + assets: [], + regions: 'auto', + }, + }) + }) + + it('should have the custom config in the manifest', async () => { + const manifest = await fs.readJSON(join(next.testDir, '.next/server/middleware-manifest.json')) + + expect(manifest.functions['/api/edge-search-params']).toHaveProperty('regions', 'default') + }) + + it('should have correct files in manifest', async () => { + const manifest = await fs.readJSON(join(next.testDir, '.next/server/middleware-manifest.json')) + for (const key of Object.keys(manifest.middleware)) { + const middleware = manifest.middleware[key] + expect(middleware.files).toContainEqual(expect.stringContaining('server/edge-runtime-webpack')) + expect(middleware.files).not.toContainEqual(expect.stringContaining('static/chunks/')) + } + }) + + it('should not run middleware for on-demand revalidate', async () => { + const bypassToken = (await fs.readJSON(join(next.testDir, '.next/prerender-manifest.json'))).preview + .previewModeId + + const res = await fetchViaHTTP(next.url, '/ssg/first', undefined, { + headers: { + 'x-prerender-revalidate': bypassToken, + }, + }) + expect(res.status).toBe(200) + expect(res.headers.get('x-middleware')).toBeFalsy() + expect(res.headers.get('x-nextjs-cache')).toBe('REVALIDATED') + }) + } + // NTL Fail + usuallySkip('passes search params with rewrites', async () => { + const response = await fetchViaHTTP(next.url, `/api/edge-search-params`, { + a: 'b', + }) + await expect(response.json()).resolves.toMatchObject({ + a: 'b', + // included from middleware + foo: 'bar', + }) + }) + + it('should have init header for NextResponse.redirect', async () => { + const res = await fetchViaHTTP(next.url, '/redirect-to-somewhere', undefined, { + redirect: 'manual', + }) + expect(res.status).toBe(307) + expect(new URL(res.headers.get('location'), 'http://n').pathname).toBe('/somewhere') + expect(res.headers.get('x-redirect-header')).toBe('hi') + }) + + it('should have correct query values for rewrite to ssg page', async () => { + const browser = await webdriver(next.url, '/to-ssg', { + waitHydration: false, + }) + const requests = [] + + browser.on('request', (req) => { + console.error('request', req.url(), req.method()) + if (req.method() === 'HEAD') { + requests.push(req.url()) + } + }) + await browser.eval('window.beforeNav = 1') + + await check(async () => { + const didReq = await browser.eval('next.router.isReady') + return didReq || requests.some((req) => new URL(req, 'http://n').pathname.endsWith('/to-ssg.json')) + ? 'found' + : JSON.stringify(requests) + }, 'found') + + await check(() => browser.eval('document.documentElement.innerHTML'), /"slug":"hello"/) + + await check(() => browser.elementByCss('body').text(), /\/to-ssg/) + + expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({ + slug: 'hello', + }) + expect(JSON.parse(await browser.elementByCss('#props').text()).params).toEqual({ + slug: 'hello', + }) + expect(await browser.elementByCss('#pathname').text()).toBe('/ssg/[slug]') + expect(await browser.elementByCss('#as-path').text()).toBe('/to-ssg') + }) + + it('should have correct dynamic route params on client-transition to dynamic route', async () => { + const browser = await webdriver(next.url, '/') + await browser.eval('window.beforeNav = 1') + await browser.eval('window.next.router.push("/blog/first")') + await browser.waitForElementByCss('#blog') + + expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({ + slug: 'first', + }) + expect(JSON.parse(await browser.elementByCss('#props').text()).params).toEqual({ + slug: 'first', + }) + expect(await browser.elementByCss('#pathname').text()).toBe('/blog/[slug]') + expect(await browser.elementByCss('#as-path').text()).toBe('/blog/first') + + await browser.eval('window.next.router.push("/blog/second")') + await check(() => browser.elementByCss('body').text(), /"slug":"second"/) + + expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({ + slug: 'second', + }) + expect(JSON.parse(await browser.elementByCss('#props').text()).params).toEqual({ + slug: 'second', + }) + expect(await browser.elementByCss('#pathname').text()).toBe('/blog/[slug]') + expect(await browser.elementByCss('#as-path').text()).toBe('/blog/second') + }) + + it('should have correct dynamic route params for middleware rewrite to dynamic route', async () => { + const browser = await webdriver(next.url, '/') + await browser.eval('window.beforeNav = 1') + await browser.eval('window.next.router.push("/rewrite-to-dynamic")') + await browser.waitForElementByCss('#blog') + + expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({ + slug: 'from-middleware', + some: 'middleware', + }) + expect(JSON.parse(await browser.elementByCss('#props').text()).params).toEqual({ + slug: 'from-middleware', + }) + expect(await browser.elementByCss('#pathname').text()).toBe('/blog/[slug]') + expect(await browser.elementByCss('#as-path').text()).toBe('/rewrite-to-dynamic') + }) + + it('should have correct route params for chained rewrite from middleware to config rewrite', async () => { + const browser = await webdriver(next.url, '/') + await browser.eval('window.beforeNav = 1') + await browser.eval('window.next.router.push("/rewrite-to-config-rewrite")') + await browser.waitForElementByCss('#blog') + + expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({ + slug: 'middleware-rewrite', + hello: 'config', + some: 'middleware', + }) + expect(JSON.parse(await browser.elementByCss('#props').text()).params).toEqual({ + slug: 'middleware-rewrite', + }) + expect(await browser.elementByCss('#pathname').text()).toBe('/blog/[slug]') + expect(await browser.elementByCss('#as-path').text()).toBe('/rewrite-to-config-rewrite') + }) + // NTL Fail + usuallySkip('should have correct route params for rewrite from config dynamic route', async () => { + const browser = await webdriver(next.url, '/') + await browser.eval('window.beforeNav = 1') + await browser.eval('window.next.router.push("/rewrite-3")') + await browser.waitForElementByCss('#blog') + + expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({ + slug: 'middleware-rewrite', + hello: 'config', + }) + expect(JSON.parse(await browser.elementByCss('#props').text()).params).toEqual({ + slug: 'middleware-rewrite', + }) + expect(await browser.elementByCss('#pathname').text()).toBe('/blog/[slug]') + expect(await browser.elementByCss('#as-path').text()).toBe('/rewrite-3') + }) + // NTL Fail + usuallySkip('should have correct route params for rewrite from config non-dynamic route', async () => { + const browser = await webdriver(next.url, '/') + await browser.eval('window.beforeNav = 1') + await browser.eval('window.next.router.push("/rewrite-1")') + + await check(() => browser.eval('document.documentElement.innerHTML'), /Hello World/) + + expect(await browser.eval('window.next.router.query')).toEqual({ + from: 'config', + }) + }) + // NTL Fail + usuallySkip('should redirect the same for direct visit and client-transition', async () => { + const res = await fetchViaHTTP(next.url, `/redirect-1`, undefined, { + redirect: 'manual', + }) + expect(res.status).toBe(307) + expect(new URL(res.headers.get('location'), 'http://n').pathname).toBe('/somewhere/else') + + const browser = await webdriver(next.url, `/`) + await browser.eval(`next.router.push('/redirect-1')`) + await check(async () => { + const pathname = await browser.eval('location.pathname') + return pathname === '/somewhere/else' ? 'success' : pathname + }, 'success') + }) + // NTL Fail + usuallySkip('should rewrite the same for direct visit and client-transition', async () => { + const res = await fetchViaHTTP(next.url, `/rewrite-1`) + expect(res.status).toBe(200) + expect(await res.text()).toContain('Hello World') + + const browser = await webdriver(next.url, `/`) + await browser.eval('window.beforeNav = 1') + await browser.eval(`next.router.push('/rewrite-1')`) + await check(async () => { + const content = await browser.eval('document.documentElement.innerHTML') + return content.includes('Hello World') ? 'success' : content + }, 'success') + expect(await browser.eval('window.beforeNav')).toBe(1) + }) + // NTL Fail + usuallySkip('should rewrite correctly for non-SSG/SSP page', async () => { + const res = await fetchViaHTTP(next.url, `/rewrite-2`) + expect(res.status).toBe(200) + expect(await res.text()).toContain('AboutA') + + const browser = await webdriver(next.url, `/`) + await browser.eval(`next.router.push('/rewrite-2')`) + await check(async () => { + const content = await browser.eval('document.documentElement.innerHTML') + return content.includes('AboutA') ? 'success' : content + }, 'success') + }) + + it('should respond with 400 on decode failure', async () => { + const res = await fetchViaHTTP(next.url, `/%2`) + expect(res.status).toBe(400) + + if ((global as any).isNextStart) { + expect(await res.text()).toContain('Bad Request') + } + }) + + if (!(global as any).isNextDeploy) { + // user agent differs on Vercel + it('should set fetch user agent correctly', async () => { + const res = await fetchViaHTTP(next.url, `/fetch-user-agent-default`) + + expect(readMiddlewareJSON(res).headers['user-agent']).toBe('Next.js Middleware') + + const res2 = await fetchViaHTTP(next.url, `/fetch-user-agent-crypto`) + expect(readMiddlewareJSON(res2).headers['user-agent']).toBe('custom-agent') + }) + } + // NTL Fail + usuallySkip('should contain process polyfill', async () => { + const res = await fetchViaHTTP(next.url, `/global`) + expect(readMiddlewareJSON(res)).toEqual({ + process: { + env: { + ANOTHER_MIDDLEWARE_TEST: 'asdf2', + STRING_ENV_VAR: 'asdf3', + MIDDLEWARE_TEST: 'asdf', + ...((global as any).isNextDeploy + ? {} + : { + NEXT_RUNTIME: 'edge', + }), + }, + }, + }) + }) + + it(`should contain \`globalThis\``, async () => { + const res = await fetchViaHTTP(next.url, '/globalthis') + expect(readMiddlewareJSON(res).length > 0).toBe(true) + }) + + it(`should contain crypto APIs`, async () => { + const res = await fetchViaHTTP(next.url, '/webcrypto') + expect('error' in readMiddlewareJSON(res)).toBe(false) + }) + + if (!(global as any).isNextDeploy) { + it(`should accept a URL instance for fetch`, async () => { + const response = await fetchViaHTTP(next.url, '/fetch-url') + // TODO: why is an error expected here if it should work? + const { error } = readMiddlewareJSON(response) + expect(error).toBeTruthy() + expect(error.message).not.toContain("Failed to construct 'URL'") + }) + } + // Fail + usuallySkip(`should allow to abort a fetch request`, async () => { + const response = await fetchViaHTTP(next.url, '/abort-controller') + const payload = readMiddlewareJSON(response) + expect('error' in payload).toBe(true) + expect(payload.error.name).toBe('AbortError') + expect(payload.error.message).toContain('The operation was aborted') + }) + + it(`should validate & parse request url from any route`, async () => { + const res = await fetchViaHTTP(next.url, `/static`) + + expect(res.headers.get('req-url-basepath')).toBeFalsy() + expect(res.headers.get('req-url-pathname')).toBe('/static') + + const { pathname, params } = JSON.parse(res.headers.get('req-url-params')) + expect(pathname).toBe(undefined) + expect(params).toEqual(undefined) + + expect(res.headers.get('req-url-query')).not.toBe('bar') + }) + + if (i18n) { + it(`should validate & parse request url from a dynamic route with params`, async () => { + const res = await fetchViaHTTP(next.url, `/fr/1`) + + expect(res.headers.get('req-url-basepath')).toBeFalsy() + expect(res.headers.get('req-url-pathname')).toBe('/1') + + const { pathname, params } = JSON.parse(res.headers.get('req-url-params')) + expect(pathname).toBe('/:locale/:id') + expect(params).toEqual({ locale: 'fr', id: '1' }) + + expect(res.headers.get('req-url-query')).not.toBe('bar') + expect(res.headers.get('req-url-locale')).toBe('fr') + }) + + it(`should validate & parse request url from a dynamic route with params and no query`, async () => { + const res = await fetchViaHTTP(next.url, `/fr/abc123`) + expect(res.headers.get('req-url-basepath')).toBeFalsy() + + const { pathname, params } = JSON.parse(res.headers.get('req-url-params')) + expect(pathname).toBe('/:locale/:id') + expect(params).toEqual({ locale: 'fr', id: 'abc123' }) + + expect(res.headers.get('req-url-query')).not.toBe('bar') + expect(res.headers.get('req-url-locale')).toBe('fr') + }) + } + + it(`should validate & parse request url from a dynamic route with params and query`, async () => { + const res = await fetchViaHTTP(next.url, `/abc123?foo=bar`) + expect(res.headers.get('req-url-basepath')).toBeFalsy() + + const { pathname, params } = JSON.parse(res.headers.get('req-url-params')) + + expect(pathname).toBe('/:id') + expect(params).toEqual({ id: 'abc123' }) + + expect(res.headers.get('req-url-query')).toBe('bar') + + if (i18n) { + expect(res.headers.get('req-url-locale')).toBe('en') + } + }) + + it('should throw when using URL with a relative URL', async () => { + const res = await fetchViaHTTP(next.url, `/url/relative-url`) + expect(readMiddlewareError(res)).toContain('Invalid URL') + }) + + it('should throw when using NextRequest with a relative URL', async () => { + const response = await fetchViaHTTP(next.url, `/url/relative-next-request`) + expect(readMiddlewareError(response)).toContain(urlsError) + }) + + if (!(global as any).isNextDeploy) { + // these errors differ on Vercel + it('should throw when using Request with a relative URL', async () => { + const response = await fetchViaHTTP(next.url, `/url/relative-request`) + expect(readMiddlewareError(response)).toContain(urlsError) + }) + + it('should warn when using Response.redirect with a relative URL', async () => { + const response = await fetchViaHTTP(next.url, `/url/relative-redirect`) + expect(readMiddlewareError(response)).toContain(urlsError) + }) + } + + it('should warn when using NextResponse.redirect with a relative URL', async () => { + const response = await fetchViaHTTP(next.url, `/url/relative-next-redirect`) + expect(readMiddlewareError(response)).toContain(urlsError) + }) + + it('should throw when using NextResponse.rewrite with a relative URL', async () => { + const response = await fetchViaHTTP(next.url, `/url/relative-next-rewrite`) + expect(readMiddlewareError(response)).toContain(urlsError) + }) + + it('should trigger middleware for data requests', async () => { + const browser = await webdriver(next.url, `/ssr-page`) + const text = await browser.elementByCss('h1').text() + expect(text).toEqual('Bye Cruel World') + const res = await fetchViaHTTP(next.url, `/_next/data/${next.buildId}${i18n ? '/en' : ''}/ssr-page.json`) + const json = await res.json() + expect(json.pageProps.message).toEqual('Bye Cruel World') + }) + + it('should normalize data requests into page requests', async () => { + const res = await fetchViaHTTP(next.url, `/_next/data/${next.buildId}${i18n ? '/en' : ''}/send-url.json`) + expect(res.headers.get('req-url-path')).toEqual('/send-url') + }) + + it('should keep non data requests in their original shape', async () => { + const res = await fetchViaHTTP(next.url, `/_next/static/${next.buildId}/_devMiddlewareManifest.json?foo=1`) + expect(res.headers.get('req-url-path')).toEqual(`/_next/static/${next.buildId}/_devMiddlewareManifest.json?foo=1`) + }) + + it('should add a rewrite header on data requests for rewrites', async () => { + const res = await fetchViaHTTP(next.url, `/ssr-page`) + const dataRes = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}${i18n ? '/en' : ''}/ssr-page.json`, + undefined, + { headers: { 'x-nextjs-data': '1' } }, + ) + const json = await dataRes.json() + expect(json.pageProps.message).toEqual('Bye Cruel World') + expect(res.headers.get('x-nextjs-matched-path')).toBeNull() + expect(dataRes.headers.get('x-nextjs-matched-path')).toEqual(`${i18n ? '/en' : ''}/ssr-page-2`) + }) + + it(`hard-navigates when the data request failed`, async () => { + const browser = await webdriver(next.url, `/error`) + await browser.eval('window.__SAME_PAGE = true') + await browser.elementByCss('#throw-on-data').click() + await browser.waitForElementByCss('.refreshed') + expect(await browser.eval('window.__SAME_PAGE')).toBeUndefined() + }) + + it('allows shallow linking with middleware', async () => { + const browser = await webdriver(next.url, '/sha') + const getMessageContents = () => browser.elementById('message-contents').text() + const ssrMessage = await getMessageContents() + const requests: string[] = [] + + browser.on('request', (x) => { + requests.push(x.url()) + }) + + browser.elementById('deep-link').click() + browser.waitForElementByCss('[data-query-hello="goodbye"]') + const deepLinkMessage = await getMessageContents() + expect(deepLinkMessage).not.toEqual(ssrMessage) + + // Changing the route with a shallow link should not cause a server request + browser.elementById('shallow-link').click() + browser.waitForElementByCss('[data-query-hello="world"]') + expect(await getMessageContents()).toEqual(deepLinkMessage) + + // Check that no server requests were made to ?hello=world, + // as it's a shallow request. + expect(requests).toEqual([`${next.url}/_next/data/${next.buildId}${i18n ? '/en' : ''}/sha.json?hello=goodbye`]) + }) + } + describe('with i18n', () => { + setup({ i18n: true }) + runTests({ i18n: true }) + }) + + describe('without i18n', () => { + setup({ i18n: false }) + runTests({ i18n: false }) + }) +}) diff --git a/test/e2e/modified-tests/middleware-redirects/app/middleware.js b/test/e2e/modified-tests/middleware-redirects/app/middleware.js new file mode 100644 index 0000000000..392e8f98e7 --- /dev/null +++ b/test/e2e/modified-tests/middleware-redirects/app/middleware.js @@ -0,0 +1,83 @@ +import { NextResponse } from 'next/server' + +export async function middleware(request) { + const url = request.nextUrl + + // this is needed for tests to get the BUILD_ID + if (url.pathname.startsWith('/_next/static/__BUILD_ID')) { + return NextResponse.next() + } + + if (url.pathname === '/old-home') { + if (url.searchParams.get('override') === 'external') { + return Response.redirect('https://example.vercel.sh') + } else { + url.pathname = '/new-home' + return Response.redirect(url) + } + } + + if (url.searchParams.get('foo') === 'bar') { + url.pathname = '/new-home' + url.searchParams.delete('foo') + return Response.redirect(url) + } + + // Chained redirects + if (url.pathname === '/redirect-me-alot') { + url.pathname = '/redirect-me-alot-2' + return Response.redirect(url) + } + + if (url.pathname === '/redirect-me-alot-2') { + url.pathname = '/redirect-me-alot-3' + return Response.redirect(url) + } + + if (url.pathname === '/redirect-me-alot-3') { + url.pathname = '/redirect-me-alot-4' + return Response.redirect(url) + } + + if (url.pathname === '/redirect-me-alot-4') { + url.pathname = '/redirect-me-alot-5' + return Response.redirect(url) + } + + if (url.pathname === '/redirect-me-alot-5') { + url.pathname = '/redirect-me-alot-6' + return Response.redirect(url) + } + + if (url.pathname === '/redirect-me-alot-6') { + url.pathname = '/redirect-me-alot-7' + return Response.redirect(url) + } + + if (url.pathname === '/redirect-me-alot-7') { + url.pathname = '/new-home' + return Response.redirect(url) + } + + // Infinite loop + if (url.pathname === '/infinite-loop') { + url.pathname = '/infinite-loop-1' + return Response.redirect(url) + } + + if (url.pathname === '/infinite-loop-1') { + url.pathname = '/infinite-loop' + return Response.redirect(url) + } + + if (url.pathname === '/to') { + url.pathname = url.searchParams.get('pathname') + url.searchParams.delete('pathname') + return Response.redirect(url) + } + + if (url.pathname === '/with-fragment') { + console.log(String(new URL('/new-home#fragment', url))) + return Response.redirect(new URL('/new-home#fragment', url)) + } +} diff --git a/test/e2e/modified-tests/middleware-redirects/app/next.config.js b/test/e2e/modified-tests/middleware-redirects/app/next.config.js new file mode 100644 index 0000000000..4cc6a5bfab --- /dev/null +++ b/test/e2e/modified-tests/middleware-redirects/app/next.config.js @@ -0,0 +1,15 @@ +module.exports = { + i18n: { + locales: ['en', 'fr', 'nl', 'es'], + defaultLocale: 'en', + }, + redirects() { + return [ + { + source: '/to-new', + destination: '/dynamic/new', + permanent: false, + }, + ] + }, +} diff --git a/test/e2e/modified-tests/middleware-redirects/app/pages/_app.js b/test/e2e/modified-tests/middleware-redirects/app/pages/_app.js new file mode 100644 index 0000000000..65e905445e --- /dev/null +++ b/test/e2e/modified-tests/middleware-redirects/app/pages/_app.js @@ -0,0 +1,8 @@ +export default function App({ Component, pageProps }) { + if (!pageProps || typeof pageProps !== 'object') { + throw new Error( + `Invariant: received invalid pageProps in _app, received ${pageProps}` + ) + } + return +} diff --git a/test/e2e/modified-tests/middleware-redirects/app/pages/api/ok.js b/test/e2e/modified-tests/middleware-redirects/app/pages/api/ok.js new file mode 100644 index 0000000000..fb91e8b611 --- /dev/null +++ b/test/e2e/modified-tests/middleware-redirects/app/pages/api/ok.js @@ -0,0 +1,3 @@ +export default function handler(req, res) { + res.send('ok') +} diff --git a/test/e2e/modified-tests/middleware-redirects/app/pages/dynamic/[slug].js b/test/e2e/modified-tests/middleware-redirects/app/pages/dynamic/[slug].js new file mode 100644 index 0000000000..24df8674ee --- /dev/null +++ b/test/e2e/modified-tests/middleware-redirects/app/pages/dynamic/[slug].js @@ -0,0 +1,13 @@ +export default function Account() { + return ( +

+ Welcome to a /dynamic/[slug] +

+ ) +} + +export function getServerSideProps() { + return { + props: {}, + } +} diff --git a/test/e2e/modified-tests/middleware-redirects/app/pages/index.js b/test/e2e/modified-tests/middleware-redirects/app/pages/index.js new file mode 100644 index 0000000000..0ca12beb09 --- /dev/null +++ b/test/e2e/modified-tests/middleware-redirects/app/pages/index.js @@ -0,0 +1,43 @@ +import Link from 'next/link' + +export default function Home() { + return ( +
+

Home Page

+ + Redirect me to a new version of a page + +
+ + Redirect me to an external site + +
+ Redirect me with URL params intact +
+ + Redirect me to Google (with no body response) + +
+ + Redirect me to Google (with no stream response) + +
+ Redirect me alot (chained requests) +
+ Redirect me alot (infinite loop) +
+ + Redirect me to api with locale + +
+ + Redirect me to a redirecting page of new version of page + +
+
+ ) +} diff --git a/test/e2e/modified-tests/middleware-redirects/app/pages/new-home.js b/test/e2e/modified-tests/middleware-redirects/app/pages/new-home.js new file mode 100644 index 0000000000..313011766e --- /dev/null +++ b/test/e2e/modified-tests/middleware-redirects/app/pages/new-home.js @@ -0,0 +1,7 @@ +export default function Account() { + return ( +

+ Welcome to a new page +

+ ) +} diff --git a/test/e2e/modified-tests/middleware-redirects/test/index.test.ts b/test/e2e/modified-tests/middleware-redirects/test/index.test.ts new file mode 100644 index 0000000000..f697ca4973 --- /dev/null +++ b/test/e2e/modified-tests/middleware-redirects/test/index.test.ts @@ -0,0 +1,159 @@ +/* eslint-env jest */ + +import { join } from 'path' +import cheerio from 'cheerio' +import webdriver from 'next-webdriver' +import { check, fetchViaHTTP } from 'next-test-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { createNext, FileRef } from 'e2e-utils' +const usuallySkip = process.env.RUN_SKIPPED_TESTS ? it : it.skip + +describe('Middleware Redirect', () => { + let next: NextInstance + + afterAll(() => next.destroy()) + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, '../app/pages')), + 'middleware.js': new FileRef(join(__dirname, '../app/middleware.js')), + 'next.config.js': new FileRef(join(__dirname, '../app/next.config.js')), + }, + }) + }) + function tests() { + usuallySkip('should redirect correctly with redirect in next.config.js', async () => { + const browser = await webdriver(next.url, '/') + await browser.eval('window.beforeNav = 1') + await browser.eval('window.next.router.push("/to-new")') + await browser.waitForElementByCss('#dynamic') + expect(await browser.eval('window.beforeNav')).toBe(1) + }) + + it('does not include the locale in redirects by default', async () => { + const res = await fetchViaHTTP(next.url, `/old-home`, undefined, { + redirect: 'manual', + }) + expect(res.headers.get('location')?.endsWith('/default/about')).toEqual(false) + }) + + usuallySkip(`should redirect to data urls with data requests and internal redirects`, async () => { + const res = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/es/old-home.json`, + { override: 'internal' }, + { redirect: 'manual', headers: { 'x-nextjs-data': '1' } }, + ) + + expect(res.headers.get('x-nextjs-redirect')?.endsWith(`/es/new-home?override=internal`)).toEqual(true) + expect(res.headers.get('location')).toEqual(null) + }) + + it(`should redirect to external urls with data requests and external redirects`, async () => { + const res = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/es/old-home.json`, + { override: 'external' }, + { redirect: 'manual', headers: { 'x-nextjs-data': '1' } }, + ) + + expect(res.headers.get('x-nextjs-redirect')).toEqual('https://example.vercel.sh/') + expect(res.headers.get('location')).toEqual(null) + + const browser = await webdriver(next.url, '/') + await browser.elementByCss('#old-home-external').click() + await check(async () => { + expect(await browser.elementByCss('h1').text()).toEqual('Example Domain') + return 'yes' + }, 'yes') + }) + } + + function testsWithLocale(locale = '') { + const label = locale ? `${locale} ` : `` + + it(`${label}should redirect`, async () => { + const res = await fetchViaHTTP(next.url, `${locale}/old-home`) + const html = await res.text() + const $ = cheerio.load(html) + const browser = await webdriver(next.url, `${locale}/old-home`) + try { + expect(await browser.eval(`window.location.pathname`)).toBe(`${locale}/new-home`) + } finally { + await browser.close() + } + expect($('.title').text()).toBe('Welcome to a new page') + }) + + it(`${label}should implement internal redirects`, async () => { + const browser = await webdriver(next.url, `${locale}`) + await browser.eval('window.__SAME_PAGE = true') + await browser.elementByCss('#old-home').click() + await browser.waitForElementByCss('#new-home-title') + expect(await browser.eval('window.__SAME_PAGE')).toBe(true) + try { + expect(await browser.eval(`window.location.pathname`)).toBe(`${locale}/new-home`) + } finally { + await browser.close() + } + }) + + it(`${label}should redirect cleanly with the original url param`, async () => { + const browser = await webdriver(next.url, `${locale}/blank-page?foo=bar`) + try { + expect(await browser.eval(`window.location.href.replace(window.location.origin, '')`)).toBe( + `${locale}/new-home`, + ) + } finally { + await browser.close() + } + }) + + it(`${label}should redirect multiple times`, async () => { + const res = await fetchViaHTTP(next.url, `${locale}/redirect-me-alot`) + const browser = await webdriver(next.url, `${locale}/redirect-me-alot`) + try { + expect(await browser.eval(`window.location.pathname`)).toBe(`${locale}/new-home`) + } finally { + await browser.close() + } + const html = await res.text() + const $ = cheerio.load(html) + expect($('.title').text()).toBe('Welcome to a new page') + }) + + it(`${label}should redirect (infinite-loop)`, async () => { + await expect(fetchViaHTTP(next.url, `${locale}/infinite-loop`)).rejects.toThrow() + }) + + it(`${label}should redirect to api route with locale`, async () => { + const browser = await webdriver(next.url, `${locale}`) + await browser.elementByCss('#link-to-api-with-locale').click() + await browser.waitForCondition('window.location.pathname === "/api/ok"') + await check(() => browser.elementByCss('body').text(), 'ok') + const logs = await browser.log() + const errors = logs + .filter((x) => x.source === 'error') + .map((x) => x.message) + .join('\n') + expect(errors).not.toContain('Failed to lookup route') + }) + + // A regression test for https://github.com/vercel/next.js/pull/41501 + it(`${label}should redirect with a fragment`, async () => { + const res = await fetchViaHTTP(next.url, `${locale}/with-fragment`) + const html = await res.text() + const $ = cheerio.load(html) + const browser = await webdriver(next.url, `${locale}/with-fragment`) + try { + expect(await browser.eval(`window.location.hash`)).toBe(`#fragment`) + } finally { + await browser.close() + } + expect($('.title').text()).toBe('Welcome to a new page') + }) + } + tests() + testsWithLocale() + testsWithLocale('/fr') +}) diff --git a/test/e2e/modified-tests/middleware-rewrites/app/middleware.js b/test/e2e/modified-tests/middleware-rewrites/app/middleware.js new file mode 100644 index 0000000000..d8c89b3292 --- /dev/null +++ b/test/e2e/modified-tests/middleware-rewrites/app/middleware.js @@ -0,0 +1,135 @@ +import { NextResponse } from 'next/server' + +const PUBLIC_FILE = /\.(.*)$/ + +/** + * @param {import('next/server').NextRequest} request + */ +export async function middleware(request) { + const url = request.nextUrl + + // this is needed for tests to get the BUILD_ID + if (url.pathname.startsWith('/_next/static/__BUILD_ID')) { + return NextResponse.next() + } + + if (url.pathname.includes('/to/some/404/path')) { + return NextResponse.next({ + 'x-matched-path': '/404', + }) + } + + if (url.pathname.includes('/fallback-true-blog/rewritten')) { + request.nextUrl.pathname = '/about' + return NextResponse.rewrite(request.nextUrl) + } + + if (url.pathname.startsWith('/about') && url.searchParams.has('override')) { + const isExternal = url.searchParams.get('override') === 'external' + return NextResponse.rewrite( + isExternal + ? 'https://example.vercel.sh' + : new URL('/ab-test/a', request.url) + ) + } + + if (url.pathname === '/rewrite-to-beforefiles-rewrite') { + url.pathname = '/beforefiles-rewrite' + return NextResponse.rewrite(url) + } + + if (url.pathname === '/rewrite-to-afterfiles-rewrite') { + url.pathname = '/afterfiles-rewrite' + return NextResponse.rewrite(url) + } + + if (url.pathname.startsWith('/to-blog')) { + const slug = url.pathname.split('/').pop() + url.pathname = `/fallback-true-blog/${slug}` + return NextResponse.rewrite(url) + } + + if (url.pathname === '/rewrite-to-ab-test') { + let bucket = request.cookies.get('bucket') + if (!bucket) { + bucket = Math.random() >= 0.5 ? 'a' : 'b' + url.pathname = `/ab-test/${bucket}` + const response = NextResponse.rewrite(url) + response.cookies.set('bucket', bucket, { maxAge: 10 }) + return response + } + + url.pathname = `/${bucket}` + return NextResponse.rewrite(url) + } + + if (url.pathname === '/rewrite-me-to-about') { + url.pathname = '/about' + return NextResponse.rewrite(url, { + headers: { 'x-rewrite-target': String(url) }, + }) + } + + if (url.pathname === '/rewrite-me-with-a-colon') { + url.pathname = '/with:colon' + return NextResponse.rewrite(url) + } + + if (url.pathname === '/colon:here') { + url.pathname = '/no-colon-here' + return NextResponse.rewrite(url) + } + + if (url.pathname === '/rewrite-me-to-vercel') { + return NextResponse.rewrite('https://example.vercel.sh') + } + + if (url.pathname === '/clear-query-params') { + const allowedKeys = ['allowed'] + for (const key of [...url.searchParams.keys()]) { + if (!allowedKeys.includes(key)) { + url.searchParams.delete(key) + } + } + return NextResponse.rewrite(url) + } + + if ( + url.pathname === '/rewrite-me-without-hard-navigation' || + url.searchParams.get('path') === 'rewrite-me-without-hard-navigation' + ) { + url.searchParams.set('middleware', 'foo') + url.pathname = request.cookies.has('about-bypass') + ? '/about-bypass' + : '/about' + + return NextResponse.rewrite(url, { + headers: { 'x-middleware-cache': 'no-cache' }, + }) + } + + if (url.pathname.endsWith('/dynamic-replace')) { + url.pathname = '/dynamic-fallback/catch-all' + return NextResponse.rewrite(url) + } + + if (url.pathname.startsWith('/country')) { + const locale = url.searchParams.get('my-locale') + if (locale) { + url.locale = locale + } + + const country = url.searchParams.get('country') || 'us' + if (!PUBLIC_FILE.test(url.pathname) && !url.pathname.includes('/api/')) { + url.pathname = `/country/${country}` + return NextResponse.rewrite(url) + } + } + + if (url.pathname.startsWith('/i18n')) { + url.searchParams.set('locale', url.locale) + return NextResponse.rewrite(url) + } + + return NextResponse.rewrite(request.nextUrl) +} diff --git a/test/e2e/modified-tests/middleware-rewrites/app/next.config.js b/test/e2e/modified-tests/middleware-rewrites/app/next.config.js new file mode 100644 index 0000000000..32c5d38016 --- /dev/null +++ b/test/e2e/modified-tests/middleware-rewrites/app/next.config.js @@ -0,0 +1,27 @@ +module.exports = { + i18n: { + locales: ['ja', 'en', 'fr', 'es'], + defaultLocale: 'en', + }, + rewrites() { + return { + beforeFiles: [ + { + source: '/beforefiles-rewrite', + destination: '/ab-test/a', + }, + ], + afterFiles: [ + { + source: '/afterfiles-rewrite', + destination: '/ab-test/b', + }, + { + source: '/afterfiles-rewrite-ssg', + destination: '/fallback-true-blog/first', + }, + ], + fallback: [], + } + }, +} diff --git a/test/e2e/modified-tests/middleware-rewrites/app/pages/404.js b/test/e2e/modified-tests/middleware-rewrites/app/pages/404.js new file mode 100644 index 0000000000..cc3910106c --- /dev/null +++ b/test/e2e/modified-tests/middleware-rewrites/app/pages/404.js @@ -0,0 +1,3 @@ +export default function NotFound() { + return

custom 404 page

+} diff --git a/test/e2e/modified-tests/middleware-rewrites/app/pages/[param].js b/test/e2e/modified-tests/middleware-rewrites/app/pages/[param].js new file mode 100644 index 0000000000..d33f75dd2d --- /dev/null +++ b/test/e2e/modified-tests/middleware-rewrites/app/pages/[param].js @@ -0,0 +1,12 @@ +export const getServerSideProps = ({ params, query }) => { + return { props: { params, query } } +} + +export default function Page({ params: { param }, query: { qp } }) { + return ( + <> +

{param}

+

{qp}

+ + ) +} diff --git a/test/e2e/modified-tests/middleware-rewrites/app/pages/_app.js b/test/e2e/modified-tests/middleware-rewrites/app/pages/_app.js new file mode 100644 index 0000000000..65e905445e --- /dev/null +++ b/test/e2e/modified-tests/middleware-rewrites/app/pages/_app.js @@ -0,0 +1,8 @@ +export default function App({ Component, pageProps }) { + if (!pageProps || typeof pageProps !== 'object') { + throw new Error( + `Invariant: received invalid pageProps in _app, received ${pageProps}` + ) + } + return +} diff --git a/test/e2e/modified-tests/middleware-rewrites/app/pages/ab-test/a.js b/test/e2e/modified-tests/middleware-rewrites/app/pages/ab-test/a.js new file mode 100644 index 0000000000..3497b535df --- /dev/null +++ b/test/e2e/modified-tests/middleware-rewrites/app/pages/ab-test/a.js @@ -0,0 +1,9 @@ +export default function Home() { + return

Welcome Page A

+} + +export const getServerSideProps = () => ({ + props: { + abtest: true, + }, +}) diff --git a/test/e2e/modified-tests/middleware-rewrites/app/pages/ab-test/b.js b/test/e2e/modified-tests/middleware-rewrites/app/pages/ab-test/b.js new file mode 100644 index 0000000000..4f15e239e9 --- /dev/null +++ b/test/e2e/modified-tests/middleware-rewrites/app/pages/ab-test/b.js @@ -0,0 +1,9 @@ +export default function Home() { + return

Welcome Page B

+} + +export const getServerSideProps = () => ({ + props: { + abtest: true, + }, +}) diff --git a/test/e2e/modified-tests/middleware-rewrites/app/pages/about-bypass.js b/test/e2e/modified-tests/middleware-rewrites/app/pages/about-bypass.js new file mode 100644 index 0000000000..4039fc76ad --- /dev/null +++ b/test/e2e/modified-tests/middleware-rewrites/app/pages/about-bypass.js @@ -0,0 +1,12 @@ +export default function AboutBypass({ message }) { + return ( +
+

About Bypassed Page

+

{message}

+
+ ) +} + +export const getServerSideProps = ({ query }) => ({ + props: { message: query.message || '' }, +}) diff --git a/test/e2e/modified-tests/middleware-rewrites/app/pages/about.js b/test/e2e/modified-tests/middleware-rewrites/app/pages/about.js new file mode 100644 index 0000000000..4eff796b31 --- /dev/null +++ b/test/e2e/modified-tests/middleware-rewrites/app/pages/about.js @@ -0,0 +1,16 @@ +export default function Main({ message, middleware }) { + return ( +
+

About Page

+

{message}

+

{middleware}

+
+ ) +} + +export const getServerSideProps = ({ query }) => ({ + props: { + middleware: query.middleware || '', + message: query.message || '', + }, +}) diff --git a/test/e2e/modified-tests/middleware-rewrites/app/pages/clear-query-params.js b/test/e2e/modified-tests/middleware-rewrites/app/pages/clear-query-params.js new file mode 100644 index 0000000000..2901a155e8 --- /dev/null +++ b/test/e2e/modified-tests/middleware-rewrites/app/pages/clear-query-params.js @@ -0,0 +1,12 @@ +export default function ClearQueryParams(props) { + return
{JSON.stringify(props.query)}
+} + +/** @type {import('next').GetServerSideProps} */ +export const getServerSideProps = (req) => { + return { + props: { + query: { ...req.query }, + }, + } +} diff --git a/test/e2e/modified-tests/middleware-rewrites/app/pages/country/[country].js b/test/e2e/modified-tests/middleware-rewrites/app/pages/country/[country].js new file mode 100644 index 0000000000..dd09ad84a7 --- /dev/null +++ b/test/e2e/modified-tests/middleware-rewrites/app/pages/country/[country].js @@ -0,0 +1,22 @@ +export const getStaticPaths = async () => { + return { + fallback: 'blocking', + paths: [], + } +} + +export const getStaticProps = async ({ params: { country }, locale }) => { + return { + props: { country, locale }, + revalidate: false, + } +} + +export default function CountryPage({ locale, country }) { + return ( +
    +
  • {country}
  • +
  • {locale}
  • +
+ ) +} diff --git a/test/e2e/modified-tests/middleware-rewrites/app/pages/dynamic-fallback/[...parts].js b/test/e2e/modified-tests/middleware-rewrites/app/pages/dynamic-fallback/[...parts].js new file mode 100644 index 0000000000..7f91f8690f --- /dev/null +++ b/test/e2e/modified-tests/middleware-rewrites/app/pages/dynamic-fallback/[...parts].js @@ -0,0 +1,5 @@ +const PartsPage = () => { + return
Parts page
+} + +export default PartsPage diff --git a/test/e2e/modified-tests/middleware-rewrites/app/pages/fallback-true-blog/[slug].js b/test/e2e/modified-tests/middleware-rewrites/app/pages/fallback-true-blog/[slug].js new file mode 100644 index 0000000000..a2aa1488ce --- /dev/null +++ b/test/e2e/modified-tests/middleware-rewrites/app/pages/fallback-true-blog/[slug].js @@ -0,0 +1,48 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' + +export default function Page(props) { + if (useRouter().isFallback) { + return

Loading...

+ } + + return ( + <> +

{JSON.stringify(props)}

+ + to /fallback-true-blog/first?hello=world + +
+ + to /fallback-true-blog/second + +
+ + ) +} + +export function getStaticPaths() { + return { + paths: [ + '/fallback-true-blog/first', + '/fallback-true-blog/build-time-1', + '/fallback-true-blog/build-time-2', + '/fallback-true-blog/build-time-3', + '/fallback-true-blog/build-time-4', + ], + fallback: true, + } +} + +export function getStaticProps({ params }) { + return { + props: { + params, + time: Date.now(), + }, + } +} diff --git a/test/e2e/modified-tests/middleware-rewrites/app/pages/i18n.js b/test/e2e/modified-tests/middleware-rewrites/app/pages/i18n.js new file mode 100644 index 0000000000..0c3d2b993d --- /dev/null +++ b/test/e2e/modified-tests/middleware-rewrites/app/pages/i18n.js @@ -0,0 +1,40 @@ +import Link from 'next/link' + +export default function Home({ locale }) { + return ( +
+
{locale}
+
    +
  • + + Go to en + +
  • +
  • + + Go to en2 + +
  • +
  • + + Go to ja + +
  • +
  • + + Go to ja2 + +
  • +
  • + + Go to fr + +
  • +
+
+ ) +} + +export const getServerSideProps = ({ query }) => ({ + props: { locale: query.locale }, +}) diff --git a/test/e2e/modified-tests/middleware-rewrites/app/pages/index.js b/test/e2e/modified-tests/middleware-rewrites/app/pages/index.js new file mode 100644 index 0000000000..817ddf4077 --- /dev/null +++ b/test/e2e/modified-tests/middleware-rewrites/app/pages/index.js @@ -0,0 +1,82 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' + +export default function Home() { + const router = useRouter() + return ( +
+

Home Page

+
+ A/B test homepage +
+ + Rewrite me to about + +
+ + Rewrite me to beforeFiles Rewrite + +
+ + Rewrite me to afterFiles Rewrite + +
+ Rewrite me to Vercel +
+ + Redirect me to Vercel (but with double reroutes) + +
+ + Rewrite me without a hard navigation + +
+ + Rewrite me to external site + +
+ + Rewrite me to internal path + +
+ + normal SSG link + + + ) +} + +export function getServerSideProps() { + return { + props: { + now: Date.now(), + }, + } +} diff --git a/test/e2e/modified-tests/middleware-rewrites/app/pages/ssg.js b/test/e2e/modified-tests/middleware-rewrites/app/pages/ssg.js new file mode 100644 index 0000000000..7bdaed70bf --- /dev/null +++ b/test/e2e/modified-tests/middleware-rewrites/app/pages/ssg.js @@ -0,0 +1,14 @@ +export default function Main({ now }) { + return ( +
+

SSG Page

+

{now}

+
+ ) +} + +export const getStaticProps = () => ({ + props: { + now: Date.now(), + }, +}) diff --git a/test/e2e/modified-tests/middleware-rewrites/test/index.test.ts b/test/e2e/modified-tests/middleware-rewrites/test/index.test.ts new file mode 100644 index 0000000000..0095a383e1 --- /dev/null +++ b/test/e2e/modified-tests/middleware-rewrites/test/index.test.ts @@ -0,0 +1,619 @@ +/* eslint-env jest */ + +import { join } from 'path' +import cheerio from 'cheerio' +import webdriver from 'next-webdriver' +import { NextInstance } from 'test/lib/next-modes/base' +import { check, fetchViaHTTP } from 'next-test-utils' +import { createNext, FileRef } from 'e2e-utils' +import escapeStringRegexp from 'escape-string-regexp' +const usuallySkip = process.env.RUN_SKIPPED_TESTS ? it : it.skip + +describe('Middleware Rewrite', () => { + let next: NextInstance + + afterAll(() => next.destroy()) + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, '../app/pages')), + 'next.config.js': new FileRef(join(__dirname, '../app/next.config.js')), + 'middleware.js': new FileRef(join(__dirname, '../app/middleware.js')), + }, + }) + }) + + function tests() { + it('should not have un-necessary data request on rewrite', async () => { + const browser = await webdriver(next.url, '/to-blog/first', { + waitHydration: false, + }) + let requests = [] + + browser.on('request', (req) => { + requests.push(new URL(req.url()).pathname) + }) + + await check(() => browser.eval(`next.router.isReady ? "yup" : "nope"`), 'yup') + + expect(requests.filter((url) => url === '/fallback-true-blog/first.json').length).toBeLessThan(2) + }) + + it('should not mix component cache when navigating between dynamic routes', async () => { + const browser = await webdriver(next.url, '/param-1') + + expect(await browser.eval('next.router.pathname')).toBe('/[param]') + expect(await browser.eval('next.router.query.param')).toBe('param-1') + + await browser.eval(`next.router.push("/fallback-true-blog/first")`) + await check(() => browser.eval('next.router.pathname'), '/fallback-true-blog/[slug]') + expect(await browser.eval('next.router.query.slug')).toBe('first') + expect(await browser.eval('next.router.asPath')).toBe('/fallback-true-blog/first') + + await browser.back() + await check(() => browser.eval('next.router.pathname'), '/[param]') + expect(await browser.eval('next.router.query.param')).toBe('param-1') + expect(await browser.eval('next.router.asPath')).toBe('/param-1') + }) + + // NTL Fail + usuallySkip('should have props for afterFiles rewrite to SSG page', async () => { + let browser = await webdriver(next.url, '/') + await browser.eval(`next.router.push("/afterfiles-rewrite-ssg")`) + + await check(() => browser.eval('next.router.isReady ? "yup": "nope"'), 'yup') + await check(() => browser.eval('document.documentElement.innerHTML'), /"slug":"first"/) + + browser = await webdriver(next.url, '/afterfiles-rewrite-ssg') + await check(() => browser.eval('next.router.isReady ? "yup": "nope"'), 'yup') + await check(() => browser.eval('document.documentElement.innerHTML'), /"slug":"first"/) + }) + + it('should hard navigate on 404 for data request', async () => { + const browser = await webdriver(next.url, '/') + await browser.eval('window.beforeNav = 1') + await browser.eval(`next.router.push("/to/some/404/path")`) + await check(() => browser.eval('document.documentElement.innerHTML'), /custom 404 page/) + expect(await browser.eval('location.pathname')).toBe('/to/some/404/path') + expect(await browser.eval('window.beforeNav')).not.toBe(1) + }) + + // TODO: middleware effect headers aren't available here + usuallySkip('includes the locale in rewrites by default', async () => { + const res = await fetchViaHTTP(next.url, `/rewrite-me-to-about`) + expect(res.headers.get('x-middleware-rewrite')?.endsWith('/en/about')).toEqual(true) + }) + + it('should rewrite correctly when navigating via history', async () => { + const browser = await webdriver(next.url, '/') + await browser.elementByCss('#override-with-internal-rewrite').click() + await check(() => { + return browser.eval('document.documentElement.innerHTML') + }, /Welcome Page A/) + + await browser.refresh() + await browser.back() + await browser.waitForElementByCss('#override-with-internal-rewrite') + await browser.forward() + await check(() => { + return browser.eval('document.documentElement.innerHTML') + }, /Welcome Page A/) + }) + + it('should rewrite correctly when navigating via history after query update', async () => { + const browser = await webdriver(next.url, '/') + await browser.elementByCss('#override-with-internal-rewrite').click() + await check(() => { + return browser.eval('document.documentElement.innerHTML') + }, /Welcome Page A/) + + await browser.refresh() + await browser.waitForCondition(`!!window.next.router.isReady`) + await browser.back() + await browser.waitForElementByCss('#override-with-internal-rewrite') + await browser.forward() + await check(() => { + return browser.eval('document.documentElement.innerHTML') + }, /Welcome Page A/) + }) + + it('should return HTML/data correctly for pre-rendered page', async () => { + for (const slug of ['first', 'build-time-1', 'build-time-2', 'build-time-3']) { + const res = await fetchViaHTTP(next.url, `/fallback-true-blog/${slug}`) + expect(res.status).toBe(200) + + const $ = cheerio.load(await res.text()) + expect(JSON.parse($('#props').text())?.params).toEqual({ + slug, + }) + + const dataRes = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/en/fallback-true-blog/${slug}.json`, + undefined, + { + headers: { + 'x-nextjs-data': '1', + }, + }, + ) + expect(dataRes.status).toBe(200) + expect((await dataRes.json())?.pageProps?.params).toEqual({ + slug, + }) + } + }) + + it('should override with rewrite internally correctly', async () => { + const res = await fetchViaHTTP(next.url, `/about`, { override: 'internal' }, { redirect: 'manual' }) + + expect(res.status).toBe(200) + expect(await res.text()).toContain('Welcome Page A') + + const browser = await webdriver(next.url, ``) + await browser.elementByCss('#override-with-internal-rewrite').click() + await check(() => browser.eval('document.documentElement.innerHTML'), /Welcome Page A/) + expect(await browser.eval('window.location.pathname')).toBe(`/about`) + expect(await browser.eval('window.location.search')).toBe('?override=internal') + }) + + it(`should rewrite to data urls for incoming data request internally rewritten`, async () => { + const res = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/es/about.json`, + { override: 'internal' }, + { redirect: 'manual', headers: { 'x-nextjs-data': '1' } }, + ) + const json = await res.json() + expect(json.pageProps).toEqual({ abtest: true }) + }) + + it('should override with rewrite externally correctly', async () => { + const res = await fetchViaHTTP(next.url, `/about`, { override: 'external' }, { redirect: 'manual' }) + + expect(res.status).toBe(200) + expect(await res.text()).toContain('Example Domain') + + const browser = await webdriver(next.url, ``) + await browser.elementByCss('#override-with-external-rewrite').click() + await check(() => browser.eval('document.documentElement.innerHTML'), /Example Domain/) + await check(() => browser.eval('window.location.pathname'), `/about`) + await check(() => browser.eval('window.location.search'), '?override=external') + }) + + it(`should rewrite to the external url for incoming data request externally rewritten`, async () => { + const browser = await webdriver( + next.url, + `/_next/data/${next.buildId}/es/about.json?override=external`, + undefined, + ) + await check(() => browser.eval('document.documentElement.innerHTML'), /Example Domain/) + }) + // NTL Fail + usuallySkip('should rewrite to fallback: true page successfully', async () => { + const randomSlug = `another-${Date.now()}` + const res2 = await fetchViaHTTP(next.url, `/to-blog/${randomSlug}`) + expect(res2.status).toBe(200) + expect(await res2.text()).toContain('Loading...') + + const randomSlug2 = `another-${Date.now()}` + const browser = await webdriver(next.url, `/to-blog/${randomSlug2}`) + + await check(async () => { + const props = JSON.parse(await browser.elementByCss('#props').text()) + return props.params.slug === randomSlug2 ? 'success' : JSON.stringify(props) + }, 'success') + }) + + it('should allow to opt-out prefetch caching', async () => { + const browser = await webdriver(next.url, '/') + await browser.addCookie({ name: 'about-bypass', value: '1' }) + await browser.refresh() + await browser.eval('window.__SAME_PAGE = true') + await browser.elementByCss('#link-with-rewritten-url').click() + await browser.waitForElementByCss('.refreshed') + await browser.deleteCookies() + expect(await browser.eval('window.__SAME_PAGE')).toBe(true) + const element = await browser.elementByCss('.title') + expect(await element.text()).toEqual('About Bypassed Page') + }) + + if (!(global as any).isNextDev) { + it('should not prefetch non-SSG routes', async () => { + const browser = await webdriver(next.url, '/') + + await check(async () => { + const hrefs = await browser.eval(`Object.keys(window.next.router.sdc)`) + for (const url of ['/en/ssg.json']) { + if (!hrefs.some((href) => href.includes(url))) { + return JSON.stringify(hrefs, null, 2) + } + } + return 'yes' + }, 'yes') + }) + } + + it(`should allow to rewrite keeping the locale in pathname`, async () => { + const res = await fetchViaHTTP(next.url, '/fr/country', { + country: 'spain', + }) + const html = await res.text() + const $ = cheerio.load(html) + expect($('#locale').text()).toBe('fr') + expect($('#country').text()).toBe('spain') + }) + + it(`should allow to rewrite to a different locale`, async () => { + const res = await fetchViaHTTP(next.url, '/country', { + 'my-locale': 'es', + }) + const html = await res.text() + const $ = cheerio.load(html) + expect($('#locale').text()).toBe('es') + expect($('#country').text()).toBe('us') + }) + + it(`should behave consistently on recursive rewrites`, async () => { + const res = await fetchViaHTTP(next.url, `/rewrite-me-to-about`, { + override: 'internal', + }) + const html = await res.text() + const $ = cheerio.load(html) + expect($('.title').text()).toBe('About Page') + + const browser = await webdriver(next.url, `/`) + await browser.elementByCss('#rewrite-me-to-about').click() + await check(() => browser.eval(`window.location.pathname`), `/rewrite-me-to-about`) + const element = await browser.elementByCss('.title') + expect(await element.text()).toEqual('About Page') + }) + + it(`should allow to switch locales`, async () => { + const browser = await webdriver(next.url, '/i18n') + await browser.waitForElementByCss('.en') + await browser.elementByCss('#link-ja').click() + await browser.waitForElementByCss('.ja') + await browser.elementByCss('#link-en').click() + await browser.waitForElementByCss('.en') + await browser.elementByCss('#link-fr').click() + await browser.waitForElementByCss('.fr') + await browser.elementByCss('#link-ja2').click() + await browser.waitForElementByCss('.ja') + await browser.elementByCss('#link-en2').click() + await browser.waitForElementByCss('.en') + }) + + it('should allow to rewrite to a `beforeFiles` rewrite config', async () => { + const res = await fetchViaHTTP(next.url, `/rewrite-to-beforefiles-rewrite`) + expect(res.status).toBe(200) + expect(await res.text()).toContain('Welcome Page A') + + const browser = await webdriver(next.url, '/') + await browser.elementByCss('#rewrite-to-beforefiles-rewrite').click() + await check(() => browser.eval('document.documentElement.innerHTML'), /Welcome Page A/) + expect(await browser.eval('window.location.pathname')).toBe(`/rewrite-to-beforefiles-rewrite`) + }) + + it('should allow to rewrite to a `afterFiles` rewrite config', async () => { + const res = await fetchViaHTTP(next.url, `/rewrite-to-afterfiles-rewrite`) + expect(res.status).toBe(200) + expect(await res.text()).toContain('Welcome Page B') + + const browser = await webdriver(next.url, '/') + await browser.elementByCss('#rewrite-to-afterfiles-rewrite').click() + await check(() => browser.eval('document.documentElement.innerHTML'), /Welcome Page B/) + expect(await browser.eval('window.location.pathname')).toBe(`/rewrite-to-afterfiles-rewrite`) + }) + + it('should have correct query info for dynamic route after query hydration', async () => { + const browser = await webdriver(next.url, '/fallback-true-blog/first?hello=world') + + await check( + () => browser.eval('next.router.query.hello === "world" ? "success" : JSON.stringify(next.router.query)'), + 'success', + ) + + expect(await browser.eval('next.router.query')).toEqual({ + slug: 'first', + hello: 'world', + }) + expect(await browser.eval('location.pathname')).toBe('/fallback-true-blog/first') + expect(await browser.eval('location.search')).toBe('?hello=world') + }) + + it('should handle shallow navigation correctly (non-dynamic page)', async () => { + const browser = await webdriver(next.url, '/about') + const requests = [] + + browser.on('request', (req) => { + const url = req.url() + if (url.includes('_next/data')) requests.push(url) + }) + + await browser.eval(`next.router.push('/about?hello=world', undefined, { shallow: true })`) + await check(() => browser.eval(`next.router.query.hello`), 'world') + + expect(await browser.eval(`next.router.pathname`)).toBe('/about') + expect(JSON.parse(await browser.eval(`JSON.stringify(next.router.query)`))).toEqual({ hello: 'world' }) + expect(await browser.eval('location.pathname')).toBe('/about') + expect(await browser.eval('location.search')).toBe('?hello=world') + + await browser.eval(`next.router.push('/about', undefined, { shallow: true })`) + await check(() => browser.eval(`next.router.query.hello || 'empty'`), 'empty') + + expect(await browser.eval(`next.router.pathname`)).toBe('/about') + expect(JSON.parse(await browser.eval(`JSON.stringify(next.router.query)`))).toEqual({}) + expect(await browser.eval('location.pathname')).toBe('/about') + expect(await browser.eval('location.search')).toBe('') + }) + + it('should handle shallow navigation correctly (dynamic page)', async () => { + const browser = await webdriver(next.url, '/fallback-true-blog/first') + + await check(async () => { + await browser.elementByCss('#to-query-shallow').click() + return browser.eval('location.search') + }, '?hello=world') + + expect(await browser.eval(`next.router.pathname`)).toBe('/fallback-true-blog/[slug]') + expect(JSON.parse(await browser.eval(`JSON.stringify(next.router.query)`))).toEqual({ + hello: 'world', + slug: 'first', + }) + expect(await browser.eval('location.pathname')).toBe('/fallback-true-blog/first') + expect(await browser.eval('location.search')).toBe('?hello=world') + + await browser.elementByCss('#to-no-query-shallow').click() + await check(() => browser.eval(`next.router.query.slug`), 'second') + + expect(await browser.eval(`next.router.pathname`)).toBe('/fallback-true-blog/[slug]') + expect(JSON.parse(await browser.eval(`JSON.stringify(next.router.query)`))).toEqual({ + slug: 'second', + }) + expect(await browser.eval('location.pathname')).toBe('/fallback-true-blog/second') + expect(await browser.eval('location.search')).toBe('') + }) + + it('should resolve dynamic route after rewrite correctly', async () => { + const browser = await webdriver(next.url, '/fallback-true-blog/first', { + waitHydration: false, + }) + let requests = [] + + browser.on('request', (req) => { + const url = new URL(req.url().replace(new RegExp(escapeStringRegexp(next.buildId)), 'BUILD_ID')).pathname + + if (url.includes('_next/data')) requests.push(url) + }) + + // wait for initial query update request + await check(async () => { + const didReq = await browser.eval('next.router.isReady') + if (requests.length > 0 || didReq) { + requests = [] + return 'yup' + } + }, 'yup') + + expect(await browser.eval(`next.router.pathname`)).toBe('/fallback-true-blog/[slug]') + expect(JSON.parse(await browser.eval(`JSON.stringify(next.router.query)`))).toEqual({ + slug: 'first', + }) + expect(await browser.eval('location.pathname')).toBe('/fallback-true-blog/first') + expect(await browser.eval('location.search')).toBe('') + + await browser.eval(`next.router.push('/fallback-true-blog/rewritten')`) + await check(() => browser.eval('document.documentElement.innerHTML'), /About Page/) + + expect(await browser.eval(`next.router.pathname`)).toBe('/about') + expect(JSON.parse(await browser.eval(`JSON.stringify(next.router.query)`))).toEqual({}) + expect(await browser.eval('location.pathname')).toBe('/fallback-true-blog/rewritten') + expect(await browser.eval('location.search')).toBe('') + expect(requests.some((req) => req === `/_next/data/BUILD_ID/en/fallback-true-blog/rewritten.json`)).toBe(true) + + await browser.eval(`next.router.push('/fallback-true-blog/second')`) + await check(() => browser.eval(`next.router.pathname`), '/fallback-true-blog/[slug]') + + expect(await browser.eval(`next.router.pathname`)).toBe('/fallback-true-blog/[slug]') + expect(JSON.parse(await browser.eval(`JSON.stringify(next.router.query)`))).toEqual({ + slug: 'second', + }) + expect(await browser.eval('location.pathname')).toBe('/fallback-true-blog/second') + expect(await browser.eval('location.search')).toBe('') + }) + } + + function testsWithLocale(locale = '') { + const label = locale ? `${locale} ` : `` + + function getCookieFromResponse(res, cookieName) { + // node-fetch bundles the cookies as string in the Response + const cookieArray = res.headers.raw()['set-cookie'] + for (const cookie of cookieArray) { + let individualCookieParams = cookie.split(';') + let individualCookie = individualCookieParams[0].split('=') + if (individualCookie[0] === cookieName) { + return individualCookie[1] + } + } + return -1 + } + + it(`${label}should add a cookie and rewrite to a/b test`, async () => { + const res = await fetchViaHTTP(next.url, `${locale}/rewrite-to-ab-test`) + const html = await res.text() + const $ = cheerio.load(html) + // Set-Cookie header with Expires should not be split into two + expect(res.headers.raw()['set-cookie']).toHaveLength(1) + const bucket = getCookieFromResponse(res, 'bucket') + const expectedText = bucket === 'a' ? 'Welcome Page A' : 'Welcome Page B' + const browser = await webdriver(next.url, `${locale}/rewrite-to-ab-test`) + try { + expect(await browser.eval(`window.location.pathname`)).toBe(`${locale}/rewrite-to-ab-test`) + } finally { + await browser.close() + } + // -1 is returned if bucket was not found in func getCookieFromResponse + expect(bucket).not.toBe(-1) + expect($('.title').text()).toBe(expectedText) + }) + + it(`${label}should clear query parameters`, async () => { + const res = await fetchViaHTTP(next.url, `${locale}/clear-query-params`, { + a: '1', + b: '2', + foo: 'bar', + allowed: 'kept', + }) + const html = await res.text() + const $ = cheerio.load(html) + expect(JSON.parse($('#my-query-params').text())).toEqual({ + allowed: 'kept', + }) + }) + + it(`${label}should rewrite to about page`, async () => { + const res = await fetchViaHTTP(next.url, `${locale}/rewrite-me-to-about`) + const html = await res.text() + const $ = cheerio.load(html) + const browser = await webdriver(next.url, `${locale}/rewrite-me-to-about`) + try { + expect(await browser.eval(`window.location.pathname`)).toBe(`${locale}/rewrite-me-to-about`) + } finally { + await browser.close() + } + expect($('.title').text()).toBe('About Page') + }) + + it(`${label}support colons in path`, async () => { + const path = `${locale}/not:param` + const res = await fetchViaHTTP(next.url, path) + const html = await res.text() + const $ = cheerio.load(html) + expect($('#props').text()).toBe('not:param') + const browser = await webdriver(next.url, path) + try { + expect(await browser.eval(`window.location.pathname`)).toBe(path) + } finally { + await browser.close() + } + }) + + it(`${label}can rewrite to path with colon`, async () => { + const path = `${locale}/rewrite-me-with-a-colon` + const res = await fetchViaHTTP(next.url, path) + const html = await res.text() + const $ = cheerio.load(html) + expect($('#props').text()).toBe('with:colon') + const browser = await webdriver(next.url, path) + try { + expect(await browser.eval(`window.location.pathname`)).toBe(path) + } finally { + await browser.close() + } + }) + + it(`${label}can rewrite from path with colon`, async () => { + const path = `${locale}/colon:here` + const res = await fetchViaHTTP(next.url, path) + const html = await res.text() + const $ = cheerio.load(html) + expect($('#props').text()).toBe('no-colon-here') + const browser = await webdriver(next.url, path) + try { + expect(await browser.eval(`window.location.pathname`)).toBe(path) + } finally { + await browser.close() + } + }) + + it(`${label}can rewrite from path with colon and retain query parameter`, async () => { + const path = `${locale}/colon:here?qp=arg` + const res = await fetchViaHTTP(next.url, path) + const html = await res.text() + const $ = cheerio.load(html) + expect($('#props').text()).toBe('no-colon-here') + expect($('#qp').text()).toBe('arg') + const browser = await webdriver(next.url, path) + try { + expect(await browser.eval(`window.location.href.replace(window.location.origin, '')`)).toBe(path) + } finally { + await browser.close() + } + }) + + it(`${label}can rewrite to path with colon and retain query parameter`, async () => { + const path = `${locale}/rewrite-me-with-a-colon?qp=arg` + const res = await fetchViaHTTP(next.url, path) + const html = await res.text() + const $ = cheerio.load(html) + expect($('#props').text()).toBe('with:colon') + expect($('#qp').text()).toBe('arg') + const browser = await webdriver(next.url, path) + try { + expect(await browser.eval(`window.location.href.replace(window.location.origin, '')`)).toBe(path) + } finally { + await browser.close() + } + }) + + if (!(global as any).isNextDeploy) { + it(`${label}should rewrite when not using localhost`, async () => { + const customUrl = new URL(next.url) + customUrl.hostname = 'localtest.me' + + const res = await fetchViaHTTP(customUrl.toString(), `${locale}/rewrite-me-without-hard-navigation`) + const html = await res.text() + const $ = cheerio.load(html) + expect($('.title').text()).toBe('About Page') + }) + } + + it(`${label}should rewrite to Vercel`, async () => { + const res = await fetchViaHTTP(next.url, `${locale}/rewrite-me-to-vercel`) + const html = await res.text() + // const browser = await webdriver(next.url, '/rewrite-me-to-vercel') + // TODO: running this to chech the window.location.pathname hangs for some reason; + expect(html).toContain('Example Domain') + }) + + it(`${label}should rewrite without hard navigation`, async () => { + const browser = await webdriver(next.url, '/') + await browser.eval('window.__SAME_PAGE = true') + await browser.elementByCss('#link-with-rewritten-url').click() + await browser.waitForElementByCss('.refreshed') + expect(await browser.eval('window.__SAME_PAGE')).toBe(true) + const element = await browser.elementByCss('.middleware') + expect(await element.text()).toEqual('foo') + }) + + it(`${label}should not call middleware with shallow push`, async () => { + const browser = await webdriver(next.url, '') + await browser.elementByCss('#link-to-shallow-push').click() + await browser.waitForCondition( + 'new URL(window.location.href).searchParams.get("path") === "rewrite-me-without-hard-navigation"', + ) + await expect(async () => { + await browser.waitForElementByCss('.refreshed', 500) + }).rejects.toThrow() + }) + + it(`${label}should correctly rewriting to a different dynamic path`, async () => { + const browser = await webdriver(next.url, '/dynamic-replace') + const element = await browser.elementByCss('.title') + expect(await element.text()).toEqual('Parts page') + const logs = await browser.log() + expect(logs.every((log) => log.source === 'log' || log.source === 'info')).toEqual(true) + }) + + it('should not have unexpected errors', async () => { + expect(next.cliOutput).not.toContain('unhandledRejection') + expect(next.cliOutput).not.toContain('ECONNRESET') + }) + } + + tests() + testsWithLocale() + testsWithLocale('/fr') +}) diff --git a/test/e2e/modified-tests/skip-trailing-slash-redirect/app/middleware.js b/test/e2e/modified-tests/skip-trailing-slash-redirect/app/middleware.js new file mode 100644 index 0000000000..bc28d0ae47 --- /dev/null +++ b/test/e2e/modified-tests/skip-trailing-slash-redirect/app/middleware.js @@ -0,0 +1,49 @@ +import { NextResponse } from 'next/server' + +export default function handler(req) { + console.log(req.nextUrl) + + if (req.nextUrl.pathname.startsWith('/_next/data/missing-id')) { + console.log(`missing-id rewrite: ${req.nextUrl.toString()}`) + return NextResponse.rewrite('https://example.vercel.sh') + } + + if ( + req.nextUrl.pathname.startsWith('/_next/data') && + req.nextUrl.pathname.endsWith('valid.json') + ) { + return NextResponse.rewrite('https://example.vercel.sh') + } + + if (req.nextUrl.pathname.includes('/middleware-rewrite-with-slash')) { + return NextResponse.rewrite(new URL('/another/', req.nextUrl)) + } + + if (req.nextUrl.pathname.includes('/middleware-rewrite-without-slash')) { + return NextResponse.rewrite(new URL('/another', req.nextUrl)) + } + + if (req.nextUrl.pathname === '/middleware-redirect-external-with') { + return NextResponse.redirect('https://example.vercel.sh/somewhere/', 307) + } + + if (req.nextUrl.pathname === '/middleware-redirect-external-without') { + return NextResponse.redirect('https://example.vercel.sh/somewhere', 307) + } + + if (req.nextUrl.pathname.startsWith('/api/test-cookie')) { + const res = NextResponse.next() + res.cookies.set('from-middleware', 1) + return res + } + + if (req.nextUrl.pathname === '/middleware-response-body') { + return new Response('hello from middleware', { + headers: { + 'x-from-middleware': 'true', + }, + }) + } + + return NextResponse.next() +} diff --git a/test/e2e/modified-tests/skip-trailing-slash-redirect/app/next.config.js b/test/e2e/modified-tests/skip-trailing-slash-redirect/app/next.config.js new file mode 100644 index 0000000000..83ae7164fa --- /dev/null +++ b/test/e2e/modified-tests/skip-trailing-slash-redirect/app/next.config.js @@ -0,0 +1,27 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + experimental: { + skipTrailingSlashRedirect: true, + skipMiddlewareUrlNormalize: true, + allowMiddlewareResponseBody: true, + }, + async redirects() { + return [ + { + source: '/redirect-me', + destination: '/another', + permanent: false, + }, + ] + }, + async rewrites() { + return [ + { + source: '/rewrite-me', + destination: '/another', + }, + ] + }, +} + +module.exports = nextConfig diff --git a/test/e2e/modified-tests/skip-trailing-slash-redirect/app/pages/another.js b/test/e2e/modified-tests/skip-trailing-slash-redirect/app/pages/another.js new file mode 100644 index 0000000000..b6cb822ba1 --- /dev/null +++ b/test/e2e/modified-tests/skip-trailing-slash-redirect/app/pages/another.js @@ -0,0 +1,13 @@ +import Link from 'next/link' + +export default function Page(props) { + return ( + <> +

another page

+ + to index + +
+ + ) +} diff --git a/test/e2e/modified-tests/skip-trailing-slash-redirect/app/pages/api/test-cookie-edge.js b/test/e2e/modified-tests/skip-trailing-slash-redirect/app/pages/api/test-cookie-edge.js new file mode 100644 index 0000000000..6018a22370 --- /dev/null +++ b/test/e2e/modified-tests/skip-trailing-slash-redirect/app/pages/api/test-cookie-edge.js @@ -0,0 +1,12 @@ +import { NextResponse } from 'next/server' + +export const config = { + runtime: 'experimental-edge', +} + +export default function handler(req) { + console.log('setting cookie in api route') + const res = NextResponse.json({ name: 'API' }) + res.cookies.set('hello', 'From API') + return res +} diff --git a/test/e2e/modified-tests/skip-trailing-slash-redirect/app/pages/api/test-cookie.js b/test/e2e/modified-tests/skip-trailing-slash-redirect/app/pages/api/test-cookie.js new file mode 100644 index 0000000000..4aec0e3eec --- /dev/null +++ b/test/e2e/modified-tests/skip-trailing-slash-redirect/app/pages/api/test-cookie.js @@ -0,0 +1,5 @@ +export default function handler(req, res) { + console.log('setting cookie in api route') + res.setHeader('Set-Cookie', 'hello=From API') + res.status(200).json({ name: 'API' }) +} diff --git a/test/e2e/modified-tests/skip-trailing-slash-redirect/app/pages/blog/[slug].js b/test/e2e/modified-tests/skip-trailing-slash-redirect/app/pages/blog/[slug].js new file mode 100644 index 0000000000..d981ff9ea3 --- /dev/null +++ b/test/e2e/modified-tests/skip-trailing-slash-redirect/app/pages/blog/[slug].js @@ -0,0 +1,13 @@ +import Link from 'next/link' + +export default function Page(props) { + return ( + <> +

blog page

+ + to index + +
+ + ) +} diff --git a/test/e2e/modified-tests/skip-trailing-slash-redirect/app/pages/index.js b/test/e2e/modified-tests/skip-trailing-slash-redirect/app/pages/index.js new file mode 100644 index 0000000000..04fbd07fee --- /dev/null +++ b/test/e2e/modified-tests/skip-trailing-slash-redirect/app/pages/index.js @@ -0,0 +1,17 @@ +import Link from 'next/link' + +export default function Page(props) { + return ( + <> +

index page

+ + to another + +
+ + to /blog/first + +
+ + ) +} diff --git a/test/e2e/modified-tests/skip-trailing-slash-redirect/index.test.ts b/test/e2e/modified-tests/skip-trailing-slash-redirect/index.test.ts new file mode 100644 index 0000000000..937b9e2a63 --- /dev/null +++ b/test/e2e/modified-tests/skip-trailing-slash-redirect/index.test.ts @@ -0,0 +1,172 @@ +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { check, fetchViaHTTP } from 'next-test-utils' +import { join } from 'path' +import webdriver from 'next-webdriver' +const usuallySkip = process.env.RUN_SKIPPED_TESTS ? it : it.skip + +describe('skip-trailing-slash-redirect', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: new FileRef(join(__dirname, 'app')), + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('should allow rewriting invalid buildId correctly', async () => { + const res = await fetchViaHTTP(next.url, '/_next/data/missing-id/hello.json', undefined, { + headers: { + 'x-nextjs-data': '1', + }, + }) + expect(res.status).toBe(200) + expect(await res.text()).toContain('Example Domain') + + if (!(global as any).isNextDeploy) { + await check(() => next.cliOutput, /missing-id rewrite/) + expect(next.cliOutput).toContain('/_next/data/missing-id/hello.json') + } + }) + + it('should provide original _next/data URL with skipMiddlewareUrlNormalize', async () => { + const res = await fetchViaHTTP(next.url, `/_next/data/${next.buildId}/valid.json`, undefined, { + headers: { + 'x-nextjs-data': '1', + }, + }) + expect(res.status).toBe(200) + expect(await res.text()).toContain('Example Domain') + }) + + it('should allow response body from middleware with flag', async () => { + const res = await fetchViaHTTP(next.url, '/middleware-response-body') + expect(res.status).toBe(200) + expect(res.headers.get('x-from-middleware')).toBe('true') + expect(await res.text()).toBe('hello from middleware') + }) + // NTL Skip + usuallySkip('should merge cookies from middleware and API routes correctly', async () => { + const res = await fetchViaHTTP(next.url, '/api/test-cookie', undefined, { + redirect: 'manual', + }) + expect(res.status).toBe(200) + expect(res.headers.get('set-cookie')).toEqual('from-middleware=1; Path=/, hello=From API') + }) + // NTL Skip + usuallySkip('should merge cookies from middleware and edge API routes correctly', async () => { + const res = await fetchViaHTTP(next.url, '/api/test-cookie-edge', undefined, { + redirect: 'manual', + }) + expect(res.status).toBe(200) + expect(res.headers.get('set-cookie')).toEqual('from-middleware=1; Path=/, hello=From%20API; Path=/') + }) + + if ((global as any).isNextStart) { + it('should not have trailing slash redirects in manifest', async () => { + const routesManifest = JSON.parse(await next.readFile('.next/routes-manifest.json')) + + expect( + routesManifest.redirects.some((redirect) => { + return ( + redirect.statusCode === 308 && (redirect.destination === '/:path+' || redirect.destination === '/:path+/') + ) + }), + ).toBe(false) + }) + } + + it('should correct skip URL normalizing in middleware', async () => { + let res = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/middleware-rewrite-with-slash.json`, + undefined, + { redirect: 'manual', headers: { 'x-nextjs-data': '1' } }, + ) + expect(res.headers.get('x-nextjs-rewrite').endsWith('/another/')).toBe(true) + + res = await fetchViaHTTP(next.url, `/_next/data/${next.buildId}/middleware-rewrite-without-slash.json`, undefined, { + redirect: 'manual', + headers: { 'x-nextjs-data': '1' }, + }) + expect(res.headers.get('x-nextjs-rewrite').endsWith('/another')).toBe(true) + + res = await fetchViaHTTP(next.url, '/middleware-redirect-external-with', undefined, { redirect: 'manual' }) + expect(res.status).toBe(307) + expect(res.headers.get('Location')).toBe('https://example.vercel.sh/somewhere/') + + res = await fetchViaHTTP(next.url, '/middleware-redirect-external-without', undefined, { redirect: 'manual' }) + expect(res.status).toBe(307) + expect(res.headers.get('Location')).toBe('https://example.vercel.sh/somewhere') + }) + + it('should apply config redirect correctly', async () => { + const res = await fetchViaHTTP(next.url, '/redirect-me', undefined, { + redirect: 'manual', + }) + expect(res.status).toBe(307) + expect(new URL(res.headers.get('location'), 'http://n').pathname).toBe('/another') + }) + + it('should apply config rewrites correctly', async () => { + const res = await fetchViaHTTP(next.url, '/rewrite-me', undefined, { + redirect: 'manual', + }) + expect(res.status).toBe(200) + expect(await res.text()).toContain('another page') + }) + // NTL Skip + usuallySkip('should not apply trailing slash redirect (with slash)', async () => { + const res = await fetchViaHTTP(next.url, '/another/', undefined, { + redirect: 'manual', + }) + expect(res.status).toBe(200) + expect(await res.text()).toContain('another page') + }) + + it('should not apply trailing slash redirect (without slash)', async () => { + const res = await fetchViaHTTP(next.url, '/another', undefined, { + redirect: 'manual', + }) + expect(res.status).toBe(200) + expect(await res.text()).toContain('another page') + }) + + it('should respond to index correctly', async () => { + const res = await fetchViaHTTP(next.url, '/', undefined, { + redirect: 'manual', + }) + expect(res.status).toBe(200) + expect(await res.text()).toContain('index page') + }) + + it('should respond to dynamic route correctly', async () => { + const res = await fetchViaHTTP(next.url, '/blog/first', undefined, { + redirect: 'manual', + }) + expect(res.status).toBe(200) + expect(await res.text()).toContain('blog page') + }) + + it('should navigate client side correctly', async () => { + const browser = await webdriver(next.url, '/') + + expect(await browser.eval('location.pathname')).toBe('/') + + await browser.elementByCss('#to-another').click() + await browser.waitForElementByCss('#another') + + expect(await browser.eval('location.pathname')).toBe('/another') + await browser.back() + await browser.waitForElementByCss('#index') + + expect(await browser.eval('location.pathname')).toBe('/') + + await browser.elementByCss('#to-blog-first').click() + await browser.waitForElementByCss('#blog') + + expect(await browser.eval('location.pathname')).toBe('/blog/first') + }) +}) diff --git a/test/e2e/modified-tests/streaming-ssr/custom-server/next.config.js b/test/e2e/modified-tests/streaming-ssr/custom-server/next.config.js new file mode 100644 index 0000000000..4ba52ba2c8 --- /dev/null +++ b/test/e2e/modified-tests/streaming-ssr/custom-server/next.config.js @@ -0,0 +1 @@ +module.exports = {} diff --git a/test/e2e/modified-tests/streaming-ssr/custom-server/pages/index.js b/test/e2e/modified-tests/streaming-ssr/custom-server/pages/index.js new file mode 100644 index 0000000000..560c19b54a --- /dev/null +++ b/test/e2e/modified-tests/streaming-ssr/custom-server/pages/index.js @@ -0,0 +1,7 @@ +export default function Page() { + return

streaming

+} + +export async function getServerSideProps() { + return { props: {} } +} diff --git a/test/e2e/modified-tests/streaming-ssr/custom-server/server.js b/test/e2e/modified-tests/streaming-ssr/custom-server/server.js new file mode 100644 index 0000000000..c8619cd29f --- /dev/null +++ b/test/e2e/modified-tests/streaming-ssr/custom-server/server.js @@ -0,0 +1,43 @@ +const NextServer = require('next/dist/server/next-server').default +const defaultNextConfig = + require('next/dist/server/config-shared').defaultConfig +const http = require('http') + +process.on('SIGTERM', () => process.exit(0)) +process.on('SIGINT', () => process.exit(0)) + +let handler + +const server = http.createServer(async (req, res) => { + try { + await handler(req, res) + } catch (err) { + console.error(err) + res.statusCode = 500 + res.end('internal server error') + } +}) +const currentPort = parseInt(process.env.PORT, 10) || 3000 + +server.listen(currentPort, (err) => { + if (err) { + console.error('Failed to start server', err) + process.exit(1) + } + const nextServer = new NextServer({ + hostname: 'localhost', + port: currentPort, + customServer: true, + dev: false, + conf: { + ...defaultNextConfig, + distDir: '.next', + experimental: { + ...defaultNextConfig.experimental, + }, + }, + }) + handler = nextServer.getRequestHandler() + + console.log('Listening on port', currentPort) +}) diff --git a/test/e2e/modified-tests/streaming-ssr/index.test.ts b/test/e2e/modified-tests/streaming-ssr/index.test.ts new file mode 100644 index 0000000000..d20d8ac711 --- /dev/null +++ b/test/e2e/modified-tests/streaming-ssr/index.test.ts @@ -0,0 +1,180 @@ +import { join } from 'path' +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { fetchViaHTTP, findPort, initNextServerScript, killApp, renderViaHTTP } from 'next-test-utils' +const usuallySkip = process.env.RUN_SKIPPED_TESTS ? it : it.skip + +const react18Deps = { + react: '^18.0.0', + 'react-dom': '^18.0.0', +} + +const isNextProd = !(global as any).isNextDev && !(global as any).isNextDeploy + +describe('react 18 streaming SSR with custom next configs', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + 'app/page.js': ` + export default function Page() { + return 'fake-app' /* this should not enable appDir */ + } + `, + pages: new FileRef(join(__dirname, 'streaming-ssr/pages')), + }, + nextConfig: require(join(__dirname, 'streaming-ssr/next.config.js')), + dependencies: react18Deps, + installCommand: 'npm install', + }) + }) + afterAll(() => next.destroy()) + // NTL Skip + usuallySkip('should match more specific route along with dynamic routes', async () => { + const res1 = await fetchViaHTTP(next.url, '/api/user/login') + const res2 = await fetchViaHTTP(next.url, '/api/user/any') + expect(await res1.text()).toBe('login') + expect(await res2.text()).toBe('[id]') + }) + + it('should render styled-jsx styles in streaming', async () => { + const html = await renderViaHTTP(next.url, '/') + expect(html).toContain('color:blue') + }) + // NTL Skip + usuallySkip('should redirect paths without trailing-slash and render when slash is appended', async () => { + const page = '/hello' + const redirectRes = await fetchViaHTTP(next.url, page, {}, { redirect: 'manual' }) + const res = await fetchViaHTTP(next.url, page + '/') + const html = await res.text() + + expect(redirectRes.status).toBe(308) + expect(res.status).toBe(200) + expect(html).toContain('hello nextjs') + expect(html).toContain('home') + }) + + it('should render next/router correctly in edge runtime', async () => { + const html = await renderViaHTTP(next.url, '/router') + expect(html).toContain('link') + }) + + it('should render multi-byte characters correctly in streaming', async () => { + const html = await renderViaHTTP(next.url, '/multi-byte') + expect(html).toContain('マルチバイト'.repeat(28)) + }) +}) + +if (isNextProd) { + describe('react 18 streaming SSR with custom server', () => { + let next + let server + let appPort + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, 'custom-server/pages')), + 'server.js': new FileRef(join(__dirname, 'custom-server/server.js')), + }, + nextConfig: require(join(__dirname, 'custom-server/next.config.js')), + dependencies: react18Deps, + }) + await next.stop() + + const testServer = join(next.testDir, 'server.js') + appPort = await findPort() + server = await initNextServerScript( + testServer, + /Listening/, + { + ...process.env, + PORT: appPort, + }, + undefined, + { + cwd: next.testDir, + }, + ) + }) + afterAll(async () => { + await next.destroy() + if (server) await killApp(server) + }) + it('should render page correctly under custom server', async () => { + const html = await renderViaHTTP(appPort, '/') + expect(html).toContain('streaming') + }) + }) + + describe('react 18 streaming SSR in minimal mode with node runtime', () => { + let next: NextInstance + + beforeAll(async () => { + if (isNextProd) { + process.env.NEXT_PRIVATE_MINIMAL_MODE = '1' + } + + next = await createNext({ + files: { + 'pages/index.js': ` + export default function Page() { + return

streaming

+ } + export async function getServerSideProps() { + return { props: {} } + }`, + }, + nextConfig: { + experimental: { + runtime: 'nodejs', + }, + webpack(config, { nextRuntime }) { + const path = require('path') + const fs = require('fs') + + const runtimeFilePath = path.join(__dirname, 'runtimes.txt') + let runtimeContent = '' + + try { + runtimeContent = fs.readFileSync(runtimeFilePath, 'utf8') + runtimeContent += '\n' + } catch (_) {} + + runtimeContent += nextRuntime || 'client' + + fs.writeFileSync(runtimeFilePath, runtimeContent) + return config + }, + }, + dependencies: react18Deps, + }) + }) + afterAll(() => { + if (isNextProd) { + delete process.env.NEXT_PRIVATE_MINIMAL_MODE + } + next.destroy() + }) + + it('should pass correct nextRuntime values', async () => { + const content = await next.readFile('runtimes.txt') + expect(content.split('\n').sort()).toEqual(['client', 'edge', 'nodejs']) + }) + + it('should generate html response by streaming correctly', async () => { + const html = await renderViaHTTP(next.url, '/') + expect(html).toContain('streaming') + }) + + if (isNextProd) { + it('should have generated a static 404 page', async () => { + expect(await next.readFile('.next/server/pages/404.html')).toBeTruthy() + + const res = await fetchViaHTTP(next.url, '/non-existent') + expect(res.status).toBe(404) + expect(await res.text()).toContain('This page could not be found') + }) + } + }) +} diff --git a/test/e2e/modified-tests/streaming-ssr/streaming-ssr/next.config.js b/test/e2e/modified-tests/streaming-ssr/streaming-ssr/next.config.js new file mode 100644 index 0000000000..ce3f975d0e --- /dev/null +++ b/test/e2e/modified-tests/streaming-ssr/streaming-ssr/next.config.js @@ -0,0 +1,3 @@ +module.exports = { + trailingSlash: true, +} diff --git a/test/e2e/modified-tests/streaming-ssr/streaming-ssr/pages/api/user/[id].js b/test/e2e/modified-tests/streaming-ssr/streaming-ssr/pages/api/user/[id].js new file mode 100644 index 0000000000..c5649d2074 --- /dev/null +++ b/test/e2e/modified-tests/streaming-ssr/streaming-ssr/pages/api/user/[id].js @@ -0,0 +1,7 @@ +export default async function handler() { + return new Response('[id]') +} + +export const config = { + runtime: 'experimental-edge', +} diff --git a/test/e2e/modified-tests/streaming-ssr/streaming-ssr/pages/api/user/login.js b/test/e2e/modified-tests/streaming-ssr/streaming-ssr/pages/api/user/login.js new file mode 100644 index 0000000000..31493ce1ef --- /dev/null +++ b/test/e2e/modified-tests/streaming-ssr/streaming-ssr/pages/api/user/login.js @@ -0,0 +1,3 @@ +export default async function handler(_, res) { + res.send('login') +} diff --git a/test/e2e/modified-tests/streaming-ssr/streaming-ssr/pages/hello.js b/test/e2e/modified-tests/streaming-ssr/streaming-ssr/pages/hello.js new file mode 100644 index 0000000000..10d52a4a22 --- /dev/null +++ b/test/e2e/modified-tests/streaming-ssr/streaming-ssr/pages/hello.js @@ -0,0 +1,12 @@ +import Link from 'next/link' + +export default function Page() { + return ( +
+

hello nextjs

+ home +
+ ) +} + +export const config = { runtime: 'experimental-edge' } diff --git a/test/e2e/modified-tests/streaming-ssr/streaming-ssr/pages/index.js b/test/e2e/modified-tests/streaming-ssr/streaming-ssr/pages/index.js new file mode 100644 index 0000000000..7d0c7c7738 --- /dev/null +++ b/test/e2e/modified-tests/streaming-ssr/streaming-ssr/pages/index.js @@ -0,0 +1,14 @@ +export default function Page() { + return ( +
+ +

index

+
+ ) +} + +export const config = { runtime: 'experimental-edge' } diff --git a/test/e2e/modified-tests/streaming-ssr/streaming-ssr/pages/multi-byte.js b/test/e2e/modified-tests/streaming-ssr/streaming-ssr/pages/multi-byte.js new file mode 100644 index 0000000000..f92eac96ec --- /dev/null +++ b/test/e2e/modified-tests/streaming-ssr/streaming-ssr/pages/multi-byte.js @@ -0,0 +1,9 @@ +export default function Page() { + return ( +
+

{'マルチバイト'.repeat(28)}

+
+ ) +} + +export const config = { runtime: 'experimental-edge' } diff --git a/test/e2e/modified-tests/streaming-ssr/streaming-ssr/pages/router.js b/test/e2e/modified-tests/streaming-ssr/streaming-ssr/pages/router.js new file mode 100644 index 0000000000..3407d79f07 --- /dev/null +++ b/test/e2e/modified-tests/streaming-ssr/streaming-ssr/pages/router.js @@ -0,0 +1,11 @@ +import { useRouter } from 'next/router' +import Link from 'next/link' + +export default () => { + useRouter() + return link +} + +export const config = { + runtime: 'experimental-edge', +} diff --git a/test/e2e/next-test-lib/e2e-utils.ts b/test/e2e/next-test-lib/e2e-utils.ts index f7f71132d4..952eb79888 100644 --- a/test/e2e/next-test-lib/e2e-utils.ts +++ b/test/e2e/next-test-lib/e2e-utils.ts @@ -26,7 +26,8 @@ const checkParent = (mod) => { checkParent(module) process.env.TEST_FILE_PATH = testFile - +// We only test "deploy" on Netlify +process.env.NEXT_TEST_MODE = 'deploy' let testMode = 'deploy' if (!testFileRegex.test(testFile)) { diff --git a/test/e2e/next-test-lib/next-modes/base.ts b/test/e2e/next-test-lib/next-modes/base.ts index 7d4802c54a..71800d329e 100644 --- a/test/e2e/next-test-lib/next-modes/base.ts +++ b/test/e2e/next-test-lib/next-modes/base.ts @@ -108,6 +108,7 @@ export class NextInstance { next: process.env.NEXT_TEST_VERSION || require('next/package.json').version, }, scripts: { + build: 'next build', ...pkgScripts, }, }, @@ -142,7 +143,7 @@ export class NextInstance { if (!fs.existsSync(path.join(this.testDir, 'netlify.toml'))) { const toml = /* toml */ ` [build] - command = "next build" + command = "yarn build" publish = ".next" [[plugins]] diff --git a/test/e2e/next-test-lib/next-modes/next-deploy.ts b/test/e2e/next-test-lib/next-modes/next-deploy.ts index 2cfeab62a0..7d4be7ad64 100644 --- a/test/e2e/next-test-lib/next-modes/next-deploy.ts +++ b/test/e2e/next-test-lib/next-modes/next-deploy.ts @@ -1,4 +1,4 @@ -import path from 'path' +import path, { dirname, relative } from 'path' import execa from 'execa' import fs from 'fs-extra' import { platform } from 'os' @@ -21,7 +21,7 @@ export class NextDeployInstance extends NextInstance { this._buildId = 'build-id' return } - + const testName = process.env.TEST_FILE_PATH && relative(process.cwd(), process.env.TEST_FILE_PATH) await super.createTestDir() // We use yarn because it's better at handling local dependencies await execa('yarn', [], { @@ -40,9 +40,11 @@ export class NextDeployInstance extends NextInstance { } const NETLIFY_SITE_ID = process.env.NETLIFY_SITE_ID || '1d5a5c76-d445-4ae5-b694-b0d3f2e2c395' - + console.log(`Deploys site for test: ${testName}`) try { - const statRes = await execa('ntl', ['status', '--json'], { env: { NETLIFY_SITE_ID, NODE_ENV: 'production' } }) + const statRes = await execa('ntl', ['status', '--json'], { + env: { NETLIFY_SITE_ID, NODE_ENV: 'production' }, + }) } catch (err) { if (err.message.includes("You don't appear to be in a folder that is linked to a site")) { throw new Error(`Site is not linked. Please set "NETLIFY_AUTH_TOKEN" and "NETLIFY_SITE_ID"`) @@ -52,7 +54,7 @@ export class NextDeployInstance extends NextInstance { console.log(`Deploying project at ${this.testDir}`) - const deployRes = await execa('ntl', ['deploy', '--build', '--json'], { + const deployRes = await execa('ntl', ['deploy', '--build', '--json', '--message', testName], { cwd: this.testDir, reject: false, env: { @@ -68,7 +70,7 @@ export class NextDeployInstance extends NextInstance { try { const data = JSON.parse(deployRes.stdout) this._url = data.deploy_url - console.log(`Deployed to ${this._url}`) + console.log(`Deployed to ${this._url}`, data) this._parsedUrl = new URL(this._url) } catch (err) { console.error(err) diff --git a/test/e2e/next-test-lib/next-test-utils.js b/test/e2e/next-test-lib/next-test-utils.js index f8e9935c24..d9a63b3595 100644 --- a/test/e2e/next-test-lib/next-test-utils.js +++ b/test/e2e/next-test-lib/next-test-utils.js @@ -2,7 +2,8 @@ import spawn from 'cross-spawn' import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'fs' import { writeFile } from 'fs-extra' -import { fetch } from 'undici' +import { fetch as undiciFetch } from 'undici' +import nodeFetch from 'node-fetch' import path from 'path' import qs from 'querystring' import { TextDecoderStream } from 'stream/web' @@ -114,18 +115,19 @@ async function processChunkedResponse(response) { * @returns {Promise} */ export function renderViaHTTP(appPort, pathname, query, opts) { - return fetchViaHTTP(appPort, pathname, query, opts).then(processChunkedResponse) + return fetchViaHTTP(appPort, pathname, query, opts, true).then(processChunkedResponse) } /** * @param {string | number} appPort * @param {string} pathname - * @param {Record | string | undefined} [query] - * @param {import("undici").RequestInit} [opts] - * @returns {Promise} + * @param {Record | string | undefined} query + * @param {RequestInit} opts + * @returns {Promise} */ -export function fetchViaHTTP(appPort, pathname, query, opts) { +export async function fetchViaHTTP(appPort, pathname, query = undefined, opts = undefined, useUndici = false) { const url = `${pathname}${typeof query === 'string' ? query : query ? `?${qs.stringify(query)}` : ''}` + const fetch = useUndici ? undiciFetch : nodeFetch return fetch(getFullUrl(appPort, url), opts) } @@ -376,12 +378,12 @@ export function waitFor(millis) { } // check for content in 1 second intervals timing out after -// 30 seconds +// 10 seconds export async function check(contentFn, regex, hardError = true) { let content let lastErr - for (let tries = 0; tries < 30; tries++) { + for (let tries = 0; tries < 10; tries++) { try { content = await contentFn() if (typeof regex === 'string') { @@ -398,11 +400,11 @@ export async function check(contentFn, regex, hardError = true) { lastErr = err } } - console.error('TIMED OUT CHECK: ', { regex, content, lastErr }) if (hardError) { throw new Error('TIMED OUT: ' + regex + '\n\n' + content) } + console.error('TIMED OUT CHECK: ', { regex, content, lastErr }) return false } diff --git a/test/e2e/tests/dynamic-route-interpolation/index.test.ts b/test/e2e/tests/dynamic-route-interpolation/index.test.ts new file mode 100644 index 0000000000..a7fb9b8216 --- /dev/null +++ b/test/e2e/tests/dynamic-route-interpolation/index.test.ts @@ -0,0 +1,91 @@ +import { createNext } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { renderViaHTTP } from 'next-test-utils' +import cheerio from 'cheerio' +import webdriver from 'next-webdriver' + +describe('Dynamic Route Interpolation', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/blog/[slug].js': ` + import Link from "next/link" + import { useRouter } from "next/router" + + export function getServerSideProps({ params }) { + return { props: { slug: params.slug, now: Date.now() } } + } + + export default function Page(props) { + const router = useRouter() + return ( + <> +

{props.slug}

+ + {props.now} + + + ) + } + `, + + 'pages/api/dynamic/[slug].js': ` + export default function Page(req, res) { + const { slug } = req.query + res.end('slug: ' + slug) + } + `, + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('should work', async () => { + const html = await renderViaHTTP(next.url, '/blog/a') + const $ = cheerio.load(html) + expect($('#slug').text()).toBe('a') + }) + + it('should work with parameter itself', async () => { + const html = await renderViaHTTP(next.url, '/blog/[slug]') + const $ = cheerio.load(html) + expect($('#slug').text()).toBe('[slug]') + }) + + it('should work with brackets', async () => { + const html = await renderViaHTTP(next.url, '/blog/[abc]') + const $ = cheerio.load(html) + expect($('#slug').text()).toBe('[abc]') + }) + + it('should work with parameter itself in API routes', async () => { + const text = await renderViaHTTP(next.url, '/api/dynamic/[slug]') + expect(text).toBe('slug: [slug]') + }) + + it('should work with brackets in API routes', async () => { + const text = await renderViaHTTP(next.url, '/api/dynamic/[abc]') + expect(text).toBe('slug: [abc]') + }) + + it('should bust data cache', async () => { + const browser = await webdriver(next.url, '/blog/login') + await browser.elementById('now').click() // fetch data once + const text = await browser.elementById('now').text() + await browser.elementById('now').click() // fetch data again + await browser.waitForElementByCss(`#now:not(:text("${text}"))`) + await browser.close() + }) + + it('should bust data cache with symbol', async () => { + const browser = await webdriver(next.url, '/blog/@login') + await browser.elementById('now').click() // fetch data once + const text = await browser.elementById('now').text() + await browser.elementById('now').click() // fetch data again + await browser.waitForElementByCss(`#now:not(:text("${text}"))`) + await browser.close() + }) +}) diff --git a/test/e2e/tests/edge-api-endpoints-can-receive-body/app/pages/api/edge.js b/test/e2e/tests/edge-api-endpoints-can-receive-body/app/pages/api/edge.js new file mode 100644 index 0000000000..26bcb1402f --- /dev/null +++ b/test/e2e/tests/edge-api-endpoints-can-receive-body/app/pages/api/edge.js @@ -0,0 +1,11 @@ +export default async (req) => { + if (!req.body) { + return new Response('Body is required', { status: 400 }) + } + + return new Response(`got: ${await req.text()}`) +} + +export const config = { + runtime: 'experimental-edge', +} diff --git a/test/e2e/tests/edge-api-endpoints-can-receive-body/app/pages/api/index.js b/test/e2e/tests/edge-api-endpoints-can-receive-body/app/pages/api/index.js new file mode 100644 index 0000000000..26bcb1402f --- /dev/null +++ b/test/e2e/tests/edge-api-endpoints-can-receive-body/app/pages/api/index.js @@ -0,0 +1,11 @@ +export default async (req) => { + if (!req.body) { + return new Response('Body is required', { status: 400 }) + } + + return new Response(`got: ${await req.text()}`) +} + +export const config = { + runtime: 'experimental-edge', +} diff --git a/test/e2e/tests/edge-api-endpoints-can-receive-body/index.test.ts b/test/e2e/tests/edge-api-endpoints-can-receive-body/index.test.ts new file mode 100644 index 0000000000..c33d6b1056 --- /dev/null +++ b/test/e2e/tests/edge-api-endpoints-can-receive-body/index.test.ts @@ -0,0 +1,53 @@ +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { fetchViaHTTP } from 'next-test-utils' +import path from 'path' + +describe('Edge API endpoints can receive body', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/api/edge.js': new FileRef( + path.resolve(__dirname, './app/pages/api/edge.js') + ), + 'pages/api/index.js': new FileRef( + path.resolve(__dirname, './app/pages/api/index.js') + ), + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('reads the body as text', async () => { + const res = await fetchViaHTTP( + next.url, + '/api/edge', + {}, + { + body: 'hello, world.', + method: 'POST', + } + ) + + expect(res.status).toBe(200) + expect(await res.text()).toBe('got: hello, world.') + }) + + it('reads the body from index', async () => { + const res = await fetchViaHTTP( + next.url, + '/api', + {}, + { + body: 'hello, world.', + method: 'POST', + } + ) + + expect(res.status).toBe(200) + expect(await res.text()).toBe('got: hello, world.') + }) +}) diff --git a/test/e2e/tests/edge-compiler-module-exports-preference/index.test.ts b/test/e2e/tests/edge-compiler-module-exports-preference/index.test.ts new file mode 100644 index 0000000000..b7d2b6abd3 --- /dev/null +++ b/test/e2e/tests/edge-compiler-module-exports-preference/index.test.ts @@ -0,0 +1,58 @@ +import { createNext } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { fetchViaHTTP } from 'next-test-utils' + +describe('Edge compiler module exports preference', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/index.js': ` + export default function Page() { + return

hello world

+ } + `, + 'middleware.js': ` + import { NextResponse } from 'next/server'; + import lib from 'my-lib'; + + export default (req) => { + return NextResponse.next({ + headers: { + 'x-imported': lib + } + }) + } + `, + 'my-lib/package.json': JSON.stringify({ + name: 'my-lib', + version: '1.0.0', + main: 'index.js', + browser: 'browser.js', + }), + 'my-lib/index.js': `module.exports = "Node.js"`, + 'my-lib/browser.js': `module.exports = "Browser"`, + }, + packageJson: { + scripts: { + setup: `cp -r ./my-lib ./node_modules`, + build: 'yarn setup && next build', + dev: 'yarn setup && next dev', + start: 'next start', + }, + }, + startCommand: (global as any).isNextDev ? 'yarn dev' : 'yarn start', + buildCommand: 'yarn build', + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('favors the browser export', async () => { + const response = await fetchViaHTTP(next.url, '/') + expect(Object.fromEntries(response.headers)).toMatchObject({ + 'x-imported': 'Browser', + }) + }) +}) diff --git a/test/e2e/getserversideprops/app/next.config.js b/test/e2e/tests/getserversideprops/app/next.config.js similarity index 100% rename from test/e2e/getserversideprops/app/next.config.js rename to test/e2e/tests/getserversideprops/app/next.config.js diff --git a/test/e2e/getserversideprops/app/pages/500.js b/test/e2e/tests/getserversideprops/app/pages/500.js similarity index 100% rename from test/e2e/getserversideprops/app/pages/500.js rename to test/e2e/tests/getserversideprops/app/pages/500.js diff --git a/test/e2e/getserversideprops/app/pages/_app.js b/test/e2e/tests/getserversideprops/app/pages/_app.js similarity index 100% rename from test/e2e/getserversideprops/app/pages/_app.js rename to test/e2e/tests/getserversideprops/app/pages/_app.js diff --git a/test/e2e/getserversideprops/app/pages/another/index.js b/test/e2e/tests/getserversideprops/app/pages/another/index.js similarity index 100% rename from test/e2e/getserversideprops/app/pages/another/index.js rename to test/e2e/tests/getserversideprops/app/pages/another/index.js diff --git a/test/e2e/getserversideprops/app/pages/blog/[post]/[comment].js b/test/e2e/tests/getserversideprops/app/pages/blog/[post]/[comment].js similarity index 100% rename from test/e2e/getserversideprops/app/pages/blog/[post]/[comment].js rename to test/e2e/tests/getserversideprops/app/pages/blog/[post]/[comment].js diff --git a/test/e2e/getserversideprops/app/pages/blog/[post]/index.js b/test/e2e/tests/getserversideprops/app/pages/blog/[post]/index.js similarity index 100% rename from test/e2e/getserversideprops/app/pages/blog/[post]/index.js rename to test/e2e/tests/getserversideprops/app/pages/blog/[post]/index.js diff --git a/test/e2e/getserversideprops/app/pages/blog/index.js b/test/e2e/tests/getserversideprops/app/pages/blog/index.js similarity index 100% rename from test/e2e/getserversideprops/app/pages/blog/index.js rename to test/e2e/tests/getserversideprops/app/pages/blog/index.js diff --git a/test/e2e/getserversideprops/app/pages/catchall/[...path].js b/test/e2e/tests/getserversideprops/app/pages/catchall/[...path].js similarity index 100% rename from test/e2e/getserversideprops/app/pages/catchall/[...path].js rename to test/e2e/tests/getserversideprops/app/pages/catchall/[...path].js diff --git a/test/e2e/getserversideprops/app/pages/custom-cache.js b/test/e2e/tests/getserversideprops/app/pages/custom-cache.js similarity index 100% rename from test/e2e/getserversideprops/app/pages/custom-cache.js rename to test/e2e/tests/getserversideprops/app/pages/custom-cache.js diff --git a/test/e2e/getserversideprops/app/pages/default-revalidate.js b/test/e2e/tests/getserversideprops/app/pages/default-revalidate.js similarity index 100% rename from test/e2e/getserversideprops/app/pages/default-revalidate.js rename to test/e2e/tests/getserversideprops/app/pages/default-revalidate.js diff --git a/test/e2e/getserversideprops/app/pages/early-request-end.js b/test/e2e/tests/getserversideprops/app/pages/early-request-end.js similarity index 100% rename from test/e2e/getserversideprops/app/pages/early-request-end.js rename to test/e2e/tests/getserversideprops/app/pages/early-request-end.js diff --git a/test/e2e/getserversideprops/app/pages/enoent.js b/test/e2e/tests/getserversideprops/app/pages/enoent.js similarity index 100% rename from test/e2e/getserversideprops/app/pages/enoent.js rename to test/e2e/tests/getserversideprops/app/pages/enoent.js diff --git a/test/e2e/getserversideprops/app/pages/index.js b/test/e2e/tests/getserversideprops/app/pages/index.js similarity index 100% rename from test/e2e/getserversideprops/app/pages/index.js rename to test/e2e/tests/getserversideprops/app/pages/index.js diff --git a/test/e2e/getserversideprops/app/pages/invalid-keys.js b/test/e2e/tests/getserversideprops/app/pages/invalid-keys.js similarity index 100% rename from test/e2e/getserversideprops/app/pages/invalid-keys.js rename to test/e2e/tests/getserversideprops/app/pages/invalid-keys.js diff --git a/test/e2e/getserversideprops/app/pages/non-json.js b/test/e2e/tests/getserversideprops/app/pages/non-json.js similarity index 100% rename from test/e2e/getserversideprops/app/pages/non-json.js rename to test/e2e/tests/getserversideprops/app/pages/non-json.js diff --git a/test/e2e/getserversideprops/app/pages/normal.js b/test/e2e/tests/getserversideprops/app/pages/normal.js similarity index 100% rename from test/e2e/getserversideprops/app/pages/normal.js rename to test/e2e/tests/getserversideprops/app/pages/normal.js diff --git a/test/e2e/getserversideprops/app/pages/not-found/[slug].js b/test/e2e/tests/getserversideprops/app/pages/not-found/[slug].js similarity index 100% rename from test/e2e/getserversideprops/app/pages/not-found/[slug].js rename to test/e2e/tests/getserversideprops/app/pages/not-found/[slug].js diff --git a/test/e2e/getserversideprops/app/pages/not-found/index.js b/test/e2e/tests/getserversideprops/app/pages/not-found/index.js similarity index 100% rename from test/e2e/getserversideprops/app/pages/not-found/index.js rename to test/e2e/tests/getserversideprops/app/pages/not-found/index.js diff --git a/test/e2e/getserversideprops/app/pages/promise/index.js b/test/e2e/tests/getserversideprops/app/pages/promise/index.js similarity index 100% rename from test/e2e/getserversideprops/app/pages/promise/index.js rename to test/e2e/tests/getserversideprops/app/pages/promise/index.js diff --git a/test/e2e/getserversideprops/app/pages/promise/mutate-res-no-streaming.js b/test/e2e/tests/getserversideprops/app/pages/promise/mutate-res-no-streaming.js similarity index 100% rename from test/e2e/getserversideprops/app/pages/promise/mutate-res-no-streaming.js rename to test/e2e/tests/getserversideprops/app/pages/promise/mutate-res-no-streaming.js diff --git a/test/e2e/getserversideprops/app/pages/promise/mutate-res-props.js b/test/e2e/tests/getserversideprops/app/pages/promise/mutate-res-props.js similarity index 100% rename from test/e2e/getserversideprops/app/pages/promise/mutate-res-props.js rename to test/e2e/tests/getserversideprops/app/pages/promise/mutate-res-props.js diff --git a/test/e2e/getserversideprops/app/pages/promise/mutate-res.js b/test/e2e/tests/getserversideprops/app/pages/promise/mutate-res.js similarity index 100% rename from test/e2e/getserversideprops/app/pages/promise/mutate-res.js rename to test/e2e/tests/getserversideprops/app/pages/promise/mutate-res.js diff --git a/test/e2e/getserversideprops/app/pages/refresh.js b/test/e2e/tests/getserversideprops/app/pages/refresh.js similarity index 100% rename from test/e2e/getserversideprops/app/pages/refresh.js rename to test/e2e/tests/getserversideprops/app/pages/refresh.js diff --git a/test/e2e/getserversideprops/app/pages/slow/index.js b/test/e2e/tests/getserversideprops/app/pages/slow/index.js similarity index 100% rename from test/e2e/getserversideprops/app/pages/slow/index.js rename to test/e2e/tests/getserversideprops/app/pages/slow/index.js diff --git a/test/e2e/getserversideprops/app/pages/something.js b/test/e2e/tests/getserversideprops/app/pages/something.js similarity index 100% rename from test/e2e/getserversideprops/app/pages/something.js rename to test/e2e/tests/getserversideprops/app/pages/something.js diff --git a/test/e2e/getserversideprops/app/pages/user/[user]/profile.js b/test/e2e/tests/getserversideprops/app/pages/user/[user]/profile.js similarity index 100% rename from test/e2e/getserversideprops/app/pages/user/[user]/profile.js rename to test/e2e/tests/getserversideprops/app/pages/user/[user]/profile.js diff --git a/test/e2e/getserversideprops/app/world.txt b/test/e2e/tests/getserversideprops/app/world.txt similarity index 100% rename from test/e2e/getserversideprops/app/world.txt rename to test/e2e/tests/getserversideprops/app/world.txt diff --git a/test/e2e/getserversideprops/test/index.test.ts b/test/e2e/tests/getserversideprops/test/index.test.ts similarity index 100% rename from test/e2e/getserversideprops/test/index.test.ts rename to test/e2e/tests/getserversideprops/test/index.test.ts diff --git a/test/e2e/tests/handle-non-hoisted-swc-helpers/index.test.ts b/test/e2e/tests/handle-non-hoisted-swc-helpers/index.test.ts new file mode 100644 index 0000000000..3fdef2e151 --- /dev/null +++ b/test/e2e/tests/handle-non-hoisted-swc-helpers/index.test.ts @@ -0,0 +1,38 @@ +import { createNext } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { renderViaHTTP } from 'next-test-utils' + +describe('handle-non-hoisted-swc-helpers', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/index.js': ` + export default function Page() { + return

hello world

+ } + + export function getServerSideProps() { + const helper = require('@swc/helpers/lib/_object_spread.js') + console.log(helper) + return { + props: { + now: Date.now() + } + } + } + `, + }, + installCommand: + 'npm install; mkdir -p node_modules/next/node_modules/@swc; mv node_modules/@swc/helpers node_modules/next/node_modules/@swc/', + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('should work', async () => { + const html = await renderViaHTTP(next.url, '/') + expect(html).toContain('hello world') + }) +}) diff --git a/test/e2e/tests/i18n-api-support/index.test.ts b/test/e2e/tests/i18n-api-support/index.test.ts new file mode 100644 index 0000000000..25ec819096 --- /dev/null +++ b/test/e2e/tests/i18n-api-support/index.test.ts @@ -0,0 +1,74 @@ +import { createNext } from 'e2e-utils' +import { fetchViaHTTP } from 'next-test-utils' +import { NextInstance } from 'test/lib/next-modes/base' + +describe('i18n API support', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/api/hello.js': ` + export default function handler(req, res) { + res.end('hello world') + } + `, + 'pages/api/blog/[slug].js': ` + export default function handler(req, res) { + res.end('blog/[slug]') + } + `, + }, + nextConfig: { + i18n: { + locales: ['en', 'fr'], + defaultLocale: 'en', + }, + async rewrites() { + return { + beforeFiles: [], + afterFiles: [], + fallback: [ + { + source: '/api/:path*', + destination: 'https://example.vercel.sh/', + }, + ], + } + }, + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('should respond to normal API request', async () => { + const res = await fetchViaHTTP(next.url, '/api/hello') + expect(res.status).toBe(200) + expect(await res.text()).toBe('hello world') + }) + + it('should respond to normal dynamic API request', async () => { + const res = await fetchViaHTTP(next.url, '/api/blog/first') + expect(res.status).toBe(200) + expect(await res.text()).toBe('blog/[slug]') + }) + + // TODO: re-enable after this is fixed to match on Vercel + if (!(global as any).isNextDeploy) { + it('should fallback rewrite non-matching API request', async () => { + const paths = [ + '/fr/api/hello', + '/en/api/blog/first', + '/en/api/non-existent', + '/api/non-existent', + ] + + for (const path of paths) { + const res = await fetchViaHTTP(next.url, path) + expect(res.status).toBe(200) + expect(await res.text()).toContain('Example Domain') + } + }) + } +}) diff --git a/test/e2e/tests/i18n-ignore-redirect-source-locale/app/pages/newpage.js b/test/e2e/tests/i18n-ignore-redirect-source-locale/app/pages/newpage.js new file mode 100644 index 0000000000..4a19d965bb --- /dev/null +++ b/test/e2e/tests/i18n-ignore-redirect-source-locale/app/pages/newpage.js @@ -0,0 +1,6 @@ +import { useRouter } from 'next/router' + +export default function Page() { + const router = useRouter() + return

{router.locale}

+} diff --git a/test/e2e/tests/i18n-ignore-redirect-source-locale/redirects-with-basepath.test.ts b/test/e2e/tests/i18n-ignore-redirect-source-locale/redirects-with-basepath.test.ts new file mode 100644 index 0000000000..1d6598223f --- /dev/null +++ b/test/e2e/tests/i18n-ignore-redirect-source-locale/redirects-with-basepath.test.ts @@ -0,0 +1,91 @@ +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { check } from 'next-test-utils' +import { join } from 'path' +import webdriver from 'next-webdriver' + +const locales = ['', '/en', '/sv', '/nl'] + +describe('i18n-ignore-redirect-source-locale with basepath', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, 'app/pages')), + }, + dependencies: {}, + nextConfig: { + basePath: '/basepath', + i18n: { + locales: ['en', 'sv', 'nl'], + defaultLocale: 'en', + }, + async redirects() { + return [ + { + source: '/:locale/to-sv', + destination: '/sv/newpage', + permanent: false, + locale: false, + }, + { + source: '/:locale/to-en', + destination: '/en/newpage', + permanent: false, + locale: false, + }, + { + source: '/:locale/to-slash', + destination: '/newpage', + permanent: false, + locale: false, + }, + { + source: '/:locale/to-same', + destination: '/:locale/newpage', + permanent: false, + locale: false, + }, + ] + }, + }, + }) + }) + afterAll(() => next.destroy()) + + test.each(locales)( + 'get redirected to the new page, from: %s to: sv', + async (locale) => { + const browser = await webdriver(next.url, `basepath/${locale}/to-sv`) + await check(() => browser.elementById('current-locale').text(), 'sv') + } + ) + + test.each(locales)( + 'get redirected to the new page, from: %s to: en', + async (locale) => { + const browser = await webdriver(next.url, `basepath/${locale}/to-en`) + await check(() => browser.elementById('current-locale').text(), 'en') + } + ) + + test.each(locales)( + 'get redirected to the new page, from: %s to: /', + async (locale) => { + const browser = await webdriver(next.url, `basepath/${locale}/to-slash`) + await check(() => browser.elementById('current-locale').text(), 'en') + } + ) + + test.each(locales)( + 'get redirected to the new page, from and to: %s', + async (locale) => { + const browser = await webdriver(next.url, `basepath/${locale}/to-same`) + await check( + () => browser.elementById('current-locale').text(), + locale === '' ? 'en' : locale.slice(1) + ) + } + ) +}) diff --git a/test/e2e/tests/i18n-ignore-redirect-source-locale/redirects.test.ts b/test/e2e/tests/i18n-ignore-redirect-source-locale/redirects.test.ts new file mode 100644 index 0000000000..25e328d40f --- /dev/null +++ b/test/e2e/tests/i18n-ignore-redirect-source-locale/redirects.test.ts @@ -0,0 +1,90 @@ +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { check } from 'next-test-utils' +import { join } from 'path' +import webdriver from 'next-webdriver' + +const locales = ['', '/en', '/sv', '/nl'] + +describe('i18n-ignore-redirect-source-locale', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, 'app/pages')), + }, + dependencies: {}, + nextConfig: { + i18n: { + locales: ['en', 'sv', 'nl'], + defaultLocale: 'en', + }, + async redirects() { + return [ + { + source: '/:locale/to-sv', + destination: '/sv/newpage', + permanent: false, + locale: false, + }, + { + source: '/:locale/to-en', + destination: '/en/newpage', + permanent: false, + locale: false, + }, + { + source: '/:locale/to-slash', + destination: '/newpage', + permanent: false, + locale: false, + }, + { + source: '/:locale/to-same', + destination: '/:locale/newpage', + permanent: false, + locale: false, + }, + ] + }, + }, + }) + }) + afterAll(() => next.destroy()) + + test.each(locales)( + 'get redirected to the new page, from: %s to: sv', + async (locale) => { + const browser = await webdriver(next.url, `${locale}/to-sv`) + await check(() => browser.elementById('current-locale').text(), 'sv') + } + ) + + test.each(locales)( + 'get redirected to the new page, from: %s to: en', + async (locale) => { + const browser = await webdriver(next.url, `${locale}/to-en`) + await check(() => browser.elementById('current-locale').text(), 'en') + } + ) + + test.each(locales)( + 'get redirected to the new page, from: %s to: /', + async (locale) => { + const browser = await webdriver(next.url, `${locale}/to-slash`) + await check(() => browser.elementById('current-locale').text(), 'en') + } + ) + + test.each(locales)( + 'get redirected to the new page, from and to: %s', + async (locale) => { + const browser = await webdriver(next.url, `${locale}/to-same`) + await check( + () => browser.elementById('current-locale').text(), + locale === '' ? 'en' : locale.slice(1) + ) + } + ) +}) diff --git a/test/e2e/tests/link-with-api-rewrite/app/next.config.js b/test/e2e/tests/link-with-api-rewrite/app/next.config.js new file mode 100644 index 0000000000..451e3d8fbd --- /dev/null +++ b/test/e2e/tests/link-with-api-rewrite/app/next.config.js @@ -0,0 +1,17 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + async rewrites() { + return { + beforeFiles: [ + { + source: '/:path(.*)', + has: [{ type: 'query', key: 'json', value: 'true' }], + destination: '/api/json?from=/:path', + }, + ], + } + }, +} + +module.exports = nextConfig diff --git a/test/e2e/tests/link-with-api-rewrite/app/pages/api/json.js b/test/e2e/tests/link-with-api-rewrite/app/pages/api/json.js new file mode 100644 index 0000000000..ea288af12d --- /dev/null +++ b/test/e2e/tests/link-with-api-rewrite/app/pages/api/json.js @@ -0,0 +1,5 @@ +export default async function handler(req, res) { + const from = req.query.from || '' + + return res.json({ from }) +} diff --git a/test/e2e/tests/link-with-api-rewrite/app/pages/index.js b/test/e2e/tests/link-with-api-rewrite/app/pages/index.js new file mode 100644 index 0000000000..a5324fc832 --- /dev/null +++ b/test/e2e/tests/link-with-api-rewrite/app/pages/index.js @@ -0,0 +1,14 @@ +import Link from 'next/link' + +export default function Page() { + return ( +
+ + to /some/route/for?json=true + + + to /api/json + +
+ ) +} diff --git a/test/e2e/tests/link-with-api-rewrite/index.test.ts b/test/e2e/tests/link-with-api-rewrite/index.test.ts new file mode 100644 index 0000000000..31fccb1a42 --- /dev/null +++ b/test/e2e/tests/link-with-api-rewrite/index.test.ts @@ -0,0 +1,74 @@ +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { check } from 'next-test-utils' +import { join } from 'path' +import webdriver from 'next-webdriver' + +describe('link-with-api-rewrite', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, 'app/pages')), + 'next.config.js': new FileRef(join(__dirname, 'app/next.config.js')), + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('should perform hard navigation for rewritten urls', async () => { + const browser = await webdriver(next.url, '/') + + try { + // Click the link on the page, we expect that there will be a hard + // navigation later (we do this be checking that the window global is + // unset). + await browser.eval('window.beforeNav = "hi"') + await browser.elementById('rewrite').click() + await check(() => browser.eval('window.beforeNav'), { + test: (content) => content !== 'hi', + }) + + // Check to see that we were in fact navigated to the correct page. + const pathname = await browser.eval('window.location.pathname') + expect(pathname).toBe('/some/route/for') + + // Check to see that the resulting data is coming from the right endpoint. + const text = await browser.eval( + 'window.document.documentElement.innerText' + ) + expect(text).toBe('{"from":"/some/route/for"}') + } finally { + await browser.close() + } + }) + + it('should perform hard navigation for direct urls', async () => { + const browser = await webdriver(next.url, '/') + + try { + // Click the link on the page, we expect that there will be a hard + // navigation later (we do this be checking that the window global is + // unset). + await browser.eval('window.beforeNav = "hi"') + await browser.elementById('direct').click() + await check(() => browser.eval('window.beforeNav'), { + test: (content) => content !== 'hi', + }) + + // Check to see that we were in fact navigated to the correct page. + const pathname = await browser.eval('window.location.pathname') + expect(pathname).toBe('/api/json') + + // Check to see that the resulting data is coming from the right endpoint. + const text = await browser.eval( + 'window.document.documentElement.innerText' + ) + expect(text).toBe('{"from":""}') + } finally { + await browser.close() + } + }) +}) diff --git a/test/e2e/tests/middleware-base-path/app/middleware.js b/test/e2e/tests/middleware-base-path/app/middleware.js new file mode 100644 index 0000000000..2e1bcab993 --- /dev/null +++ b/test/e2e/tests/middleware-base-path/app/middleware.js @@ -0,0 +1,24 @@ +import { NextResponse } from 'next/server' + +export async function middleware(request) { + const url = request.nextUrl + + if ( + request.method === 'HEAD' && + url.basePath === '/root' && + url.pathname === '/redirect-me-to-about' + ) { + url.pathname = '/about' + return NextResponse.redirect(url) + } + + if (url.pathname === '/redirect-with-basepath' && !url.basePath) { + url.basePath = '/root' + return NextResponse.redirect(url) + } + + if (url.pathname === '/redirect-with-basepath') { + url.pathname = '/about' + return NextResponse.rewrite(url) + } +} diff --git a/test/e2e/tests/middleware-base-path/app/next.config.js b/test/e2e/tests/middleware-base-path/app/next.config.js new file mode 100644 index 0000000000..959fceae74 --- /dev/null +++ b/test/e2e/tests/middleware-base-path/app/next.config.js @@ -0,0 +1,3 @@ +module.exports = { + basePath: '/root', +} diff --git a/test/e2e/tests/middleware-base-path/app/pages/about.js b/test/e2e/tests/middleware-base-path/app/pages/about.js new file mode 100644 index 0000000000..9924acf037 --- /dev/null +++ b/test/e2e/tests/middleware-base-path/app/pages/about.js @@ -0,0 +1,7 @@ +export default function About() { + return ( +
+

About Page

+
+ ) +} diff --git a/test/e2e/tests/middleware-base-path/app/pages/dynamic-routes/[routeName].js b/test/e2e/tests/middleware-base-path/app/pages/dynamic-routes/[routeName].js new file mode 100644 index 0000000000..1fe4784b68 --- /dev/null +++ b/test/e2e/tests/middleware-base-path/app/pages/dynamic-routes/[routeName].js @@ -0,0 +1,11 @@ +import { useRouter } from 'next/router' + +export default function DynamicRoutes() { + const { query } = useRouter() + + return ( +
+

{query.routeName}

+
+ ) +} diff --git a/test/e2e/tests/middleware-base-path/app/pages/index.js b/test/e2e/tests/middleware-base-path/app/pages/index.js new file mode 100644 index 0000000000..a65b40f43f --- /dev/null +++ b/test/e2e/tests/middleware-base-path/app/pages/index.js @@ -0,0 +1,41 @@ +import Link from 'next/link' + +export default function Main({ message }) { + return ( +
+

Hello {message}

+
    +
  • + Stream a response +
  • +
  • + + Rewrite me to about + +
  • +
  • + Rewrite me to Vercel +
  • +
  • + redirect me to about +
  • +
  • + + Hello World + +
  • +
+
+ ) +} + +export const getServerSideProps = ({ query }) => ({ + props: { message: query.message || 'World' }, +}) diff --git a/test/e2e/tests/middleware-base-path/test/index.test.ts b/test/e2e/tests/middleware-base-path/test/index.test.ts new file mode 100644 index 0000000000..dffe7a1e79 --- /dev/null +++ b/test/e2e/tests/middleware-base-path/test/index.test.ts @@ -0,0 +1,48 @@ +/* eslint-env jest */ +import { join } from 'path' +import cheerio from 'cheerio' +import webdriver from 'next-webdriver' +import { fetchViaHTTP } from 'next-test-utils' +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' + +describe('Middleware base tests', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, '../app/pages')), + 'middleware.js': new FileRef(join(__dirname, '../app/middleware.js')), + 'next.config.js': new FileRef(join(__dirname, '../app/next.config.js')), + }, + }) + }) + afterAll(() => next.destroy()) + + it('should execute from absolute paths', async () => { + const browser = await webdriver(next.url, '/redirect-with-basepath') + try { + expect(await browser.eval(`window.location.pathname`)).toBe( + '/root/redirect-with-basepath' + ) + } finally { + await browser.close() + } + + const res = await fetchViaHTTP(next.url, '/root/redirect-with-basepath') + const html = await res.text() + const $ = cheerio.load(html) + expect($('.title').text()).toBe('About Page') + }) + it('router.query must exist when Link clicked page routing', async () => { + const browser = await webdriver(next.url, '/root') + try { + await browser.elementById('go-to-hello-world-anchor').click() + const routeName = await browser.elementById('route-name').text() + expect(routeName).toMatch('hello-world') + } finally { + await browser.close() + } + }) +}) diff --git a/test/e2e/tests/middleware-custom-matchers-i18n/app/middleware.js b/test/e2e/tests/middleware-custom-matchers-i18n/app/middleware.js new file mode 100644 index 0000000000..bdd67e48df --- /dev/null +++ b/test/e2e/tests/middleware-custom-matchers-i18n/app/middleware.js @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server' + +export default function middleware(request) { + const nextUrl = request.nextUrl.clone() + nextUrl.pathname = '/' + const res = NextResponse.rewrite(nextUrl) + res.headers.set('X-From-Middleware', 'true') + return res +} + +export const config = { + matcher: [ + { + source: '/hello', + }, + { + source: '/nl-NL/about', + locale: false, + }, + ], +} diff --git a/test/e2e/tests/middleware-custom-matchers-i18n/app/next.config.js b/test/e2e/tests/middleware-custom-matchers-i18n/app/next.config.js new file mode 100644 index 0000000000..97c6addb6f --- /dev/null +++ b/test/e2e/tests/middleware-custom-matchers-i18n/app/next.config.js @@ -0,0 +1,6 @@ +module.exports = { + i18n: { + locales: ['en', 'nl-NL'], + defaultLocale: 'en', + }, +} diff --git a/test/e2e/tests/middleware-custom-matchers-i18n/app/pages/index.js b/test/e2e/tests/middleware-custom-matchers-i18n/app/pages/index.js new file mode 100644 index 0000000000..accd5b17ad --- /dev/null +++ b/test/e2e/tests/middleware-custom-matchers-i18n/app/pages/index.js @@ -0,0 +1,14 @@ +export default (props) => ( + <> +

home

+
{props.fromMiddleware}
+ +) + +export async function getServerSideProps({ res }) { + return { + props: { + fromMiddleware: res.getHeader('x-from-middleware') || null, + }, + } +} diff --git a/test/e2e/tests/middleware-custom-matchers-i18n/app/pages/routes.js b/test/e2e/tests/middleware-custom-matchers-i18n/app/pages/routes.js new file mode 100644 index 0000000000..de7469fb8b --- /dev/null +++ b/test/e2e/tests/middleware-custom-matchers-i18n/app/pages/routes.js @@ -0,0 +1,26 @@ +import Link from 'next/link' + +export default (props) => ( +
    +
  • + + /hello + +
  • +
  • + + /en/hello + +
  • +
  • + + /nl-NL/hello + +
  • +
  • + + /nl-NL/about + +
  • +
+) diff --git a/test/e2e/tests/middleware-custom-matchers-i18n/test/index.test.ts b/test/e2e/tests/middleware-custom-matchers-i18n/test/index.test.ts new file mode 100644 index 0000000000..0d81f50c74 --- /dev/null +++ b/test/e2e/tests/middleware-custom-matchers-i18n/test/index.test.ts @@ -0,0 +1,51 @@ +/* eslint-env jest */ +/* eslint-disable jest/no-standalone-expect */ + +import { join } from 'path' +import webdriver from 'next-webdriver' +import { fetchViaHTTP } from 'next-test-utils' +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' + +const itif = (condition: boolean) => (condition ? it : it.skip) + +const isModeDeploy = process.env.NEXT_TEST_MODE === 'deploy' + +describe('Middleware custom matchers i18n', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: new FileRef(join(__dirname, '../app')), + }) + }) + afterAll(() => next.destroy()) + + it.each(['/hello', '/en/hello', '/nl-NL/hello', '/nl-NL/about'])('should match', async (path) => { + const res = await fetchViaHTTP(next.url, path) + expect(res.status).toBe(200) + expect(res.headers.get('x-from-middleware')).toBeDefined() + }) + + // NTL Fail - it thinks "invalid" is a valid locale + // it.each(['/invalid/hello', '/hello/invalid', '/about', '/en/about'])( + it.each(['/hello/invalid', '/about', '/en/about'])('should not match', async (path) => { + const res = await fetchViaHTTP(next.url, path) + expect(res.status).toBe(404) + }) + + // FIXME: + // See https://linear.app/vercel/issue/EC-160/header-value-set-on-middleware-is-not-propagated-on-client-request-of + itif(!isModeDeploy).each(['hello', 'en_hello', 'nl-NL_hello', 'nl-NL_about'])( + 'should match has query on client routing', + async (id) => { + const browser = await webdriver(next.url, '/routes') + await browser.eval('window.__TEST_NO_RELOAD = true') + await browser.elementById(id).click() + const fromMiddleware = await browser.elementById('from-middleware').text() + expect(fromMiddleware).toBe('true') + const noReload = await browser.eval('window.__TEST_NO_RELOAD') + expect(noReload).toBe(true) + }, + ) +}) diff --git a/test/e2e/tests/middleware-fetches-with-any-http-method/index.test.ts b/test/e2e/tests/middleware-fetches-with-any-http-method/index.test.ts new file mode 100644 index 0000000000..fd8be9a47f --- /dev/null +++ b/test/e2e/tests/middleware-fetches-with-any-http-method/index.test.ts @@ -0,0 +1,91 @@ +import { createNext } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { fetchViaHTTP } from 'next-test-utils' + +describe('Middleware fetches with any HTTP method', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/api/ping.js': ` + export default (req, res) => { + res.send(JSON.stringify({ + method: req.method, + headers: {...req.headers}, + })) + } + `, + 'middleware.js': ` + import { NextResponse } from 'next/server'; + + const HTTP_ECHO_URL = 'https://http-echo-kou029w.vercel.app/'; + + export default async (req) => { + const kind = req.nextUrl.searchParams.get('kind') + const handler = handlers[kind] ?? handlers['normal-fetch']; + + const response = await handler({url: HTTP_ECHO_URL, method: req.method}); + const json = await response.text() + + const res = NextResponse.next(); + res.headers.set('x-resolved', json ?? '{}'); + return res + } + + const handlers = { + 'new-request': ({url, method}) => + fetch(new Request(url, { method, headers: { 'x-kind': 'new-request' } })), + + 'normal-fetch': ({url, method}) => + fetch(url, { method, headers: { 'x-kind': 'normal-fetch' } }) + } + `, + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('passes the method on a direct fetch request', async () => { + const response = await fetchViaHTTP( + next.url, + '/api/ping', + {}, + { method: 'POST' } + ) + const json = await response.json() + expect(json).toMatchObject({ + method: 'POST', + }) + + const headerJson = JSON.parse(response.headers.get('x-resolved')) + expect(headerJson).toMatchObject({ + method: 'POST', + headers: { + 'x-kind': 'normal-fetch', + }, + }) + }) + + it('passes the method when providing a Request object', async () => { + const response = await fetchViaHTTP( + next.url, + '/api/ping', + { kind: 'new-request' }, + { method: 'POST' } + ) + const json = await response.json() + expect(json).toMatchObject({ + method: 'POST', + }) + + const headerJson = JSON.parse(response.headers.get('x-resolved')) + expect(headerJson).toMatchObject({ + method: 'POST', + headers: { + 'x-kind': 'new-request', + }, + }) + }) +}) diff --git a/test/e2e/tests/middleware-matcher/app/middleware.js b/test/e2e/tests/middleware-matcher/app/middleware.js new file mode 100644 index 0000000000..546a09b541 --- /dev/null +++ b/test/e2e/tests/middleware-matcher/app/middleware.js @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server' + +export const config = { + matcher: [ + '/', + '/with-middleware/:path*', + '/another-middleware/:path*', + // the below is testing special characters don't break the build + '/_sites/:path((?![^/]*\\.json$)[^/]+$)', + ], +} + +export default (req) => { + const res = NextResponse.next() + res.headers.set('X-From-Middleware', 'true') + return res +} diff --git a/test/e2e/tests/middleware-matcher/app/pages/another-middleware.js b/test/e2e/tests/middleware-matcher/app/pages/another-middleware.js new file mode 100644 index 0000000000..57d698e054 --- /dev/null +++ b/test/e2e/tests/middleware-matcher/app/pages/another-middleware.js @@ -0,0 +1,22 @@ +import Link from 'next/link' + +export default function Page(props) { + return ( +
+

This should also run the middleware

+

{JSON.stringify(props)}

+ + to / + +
+
+ ) +} + +export const getServerSideProps = () => { + return { + props: { + message: 'Hello, magnificent world.', + }, + } +} diff --git a/test/e2e/tests/middleware-matcher/app/pages/blog/[slug].js b/test/e2e/tests/middleware-matcher/app/pages/blog/[slug].js new file mode 100644 index 0000000000..f718a7daf6 --- /dev/null +++ b/test/e2e/tests/middleware-matcher/app/pages/blog/[slug].js @@ -0,0 +1,27 @@ +import Link from 'next/link' + +export default function Page(props) { + return ( +
+

This should not run the middleware

+

{JSON.stringify(props)}

+ + to /another-middleware + +
+ + to /blog/slug-2 + +
+
+ ) +} + +export const getServerSideProps = ({ params }) => { + return { + props: { + params, + message: 'Hello, magnificent world.', + }, + } +} diff --git a/test/e2e/tests/middleware-matcher/app/pages/index.js b/test/e2e/tests/middleware-matcher/app/pages/index.js new file mode 100644 index 0000000000..5a99c14917 --- /dev/null +++ b/test/e2e/tests/middleware-matcher/app/pages/index.js @@ -0,0 +1,26 @@ +import Link from 'next/link' + +export default function Page(props) { + return ( +
+

root page

+

{JSON.stringify(props)}

+ + to /another-middleware + +
+ + to /blog/slug-1 + +
+
+ ) +} + +export const getServerSideProps = () => { + return { + props: { + message: 'Hello, world.', + }, + } +} diff --git a/test/e2e/tests/middleware-matcher/app/pages/with-middleware.js b/test/e2e/tests/middleware-matcher/app/pages/with-middleware.js new file mode 100644 index 0000000000..da078df25f --- /dev/null +++ b/test/e2e/tests/middleware-matcher/app/pages/with-middleware.js @@ -0,0 +1,16 @@ +export default function Page({ message }) { + return ( +
+

This should run the middleware

+

{message}

+
+ ) +} + +export const getServerSideProps = () => { + return { + props: { + message: 'Hello, cruel world.', + }, + } +} diff --git a/test/e2e/tests/middleware-matcher/index.test.ts b/test/e2e/tests/middleware-matcher/index.test.ts new file mode 100644 index 0000000000..2049c560eb --- /dev/null +++ b/test/e2e/tests/middleware-matcher/index.test.ts @@ -0,0 +1,530 @@ +/* eslint-disable jest/no-identical-title */ +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { check, fetchViaHTTP } from 'next-test-utils' +import { join } from 'path' +import webdriver from 'next-webdriver' + +describe('Middleware can set the matcher in its config', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: new FileRef(join(__dirname, 'app')), + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('does add the header for root request', async () => { + const response = await fetchViaHTTP(next.url, '/') + expect(response.headers.get('X-From-Middleware')).toBe('true') + expect(await response.text()).toContain('root page') + }) + + it('adds the header for a matched path', async () => { + const response = await fetchViaHTTP(next.url, '/with-middleware') + expect(response.headers.get('X-From-Middleware')).toBe('true') + expect(await response.text()).toContain('This should run the middleware') + }) + + it('adds the header for a matched data path (with header)', async () => { + const response = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/with-middleware.json`, + undefined, + { headers: { 'x-nextjs-data': '1' } } + ) + expect(await response.json()).toMatchObject({ + pageProps: { + message: 'Hello, cruel world.', + }, + }) + expect(response.headers.get('X-From-Middleware')).toBe('true') + }) + + it('adds the header for a matched data path (without header)', async () => { + const response = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/with-middleware.json` + ) + expect(await response.json()).toMatchObject({ + pageProps: { + message: 'Hello, cruel world.', + }, + }) + expect(response.headers.get('X-From-Middleware')).toBe('true') + }) + + it('adds the header for another matched path', async () => { + const response = await fetchViaHTTP(next.url, '/another-middleware') + expect(response.headers.get('X-From-Middleware')).toBe('true') + expect(await response.text()).toContain( + 'This should also run the middleware' + ) + }) + + it('adds the header for another matched data path', async () => { + const response = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/another-middleware.json`, + undefined, + { headers: { 'x-nextjs-data': '1' } } + ) + expect(await response.json()).toMatchObject({ + pageProps: { + message: 'Hello, magnificent world.', + }, + }) + expect(response.headers.get('X-From-Middleware')).toBe('true') + }) + + it('does add the header for root data request', async () => { + const response = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/index.json`, + undefined, + { headers: { 'x-nextjs-data': '1' } } + ) + expect(await response.json()).toMatchObject({ + pageProps: { + message: 'Hello, world.', + }, + }) + expect(response.headers.get('X-From-Middleware')).toBe('true') + }) + + it('should load matches in client matchers correctly', async () => { + const browser = await webdriver(next.url, '/') + + await check(async () => { + const matchers = await browser.eval( + (global as any).isNextDev + ? 'window.__DEV_MIDDLEWARE_MATCHERS' + : 'window.__MIDDLEWARE_MATCHERS' + ) + + return matchers && + matchers.some((m) => m.regexp.includes('with-middleware')) && + matchers.some((m) => m.regexp.includes('another-middleware')) + ? 'success' + : 'failed' + }, 'success') + }) + + it('should navigate correctly with matchers', async () => { + const browser = await webdriver(next.url, '/') + await browser.eval('window.beforeNav = 1') + + await browser.elementByCss('#to-another-middleware').click() + await browser.waitForElementByCss('#another-middleware') + + expect(JSON.parse(await browser.elementByCss('#props').text())).toEqual({ + message: 'Hello, magnificent world.', + }) + + await browser.elementByCss('#to-index').click() + await browser.waitForElementByCss('#index') + + await browser.elementByCss('#to-blog-slug-1').click() + await browser.waitForElementByCss('#blog') + expect(JSON.parse(await browser.elementByCss('#props').text())).toEqual({ + message: 'Hello, magnificent world.', + params: { + slug: 'slug-1', + }, + }) + + await browser.elementByCss('#to-blog-slug-2').click() + await check( + () => browser.eval('document.documentElement.innerHTML'), + /"slug":"slug-2"/ + ) + expect(JSON.parse(await browser.elementByCss('#props').text())).toEqual({ + message: 'Hello, magnificent world.', + params: { + slug: 'slug-2', + }, + }) + }) +}) + +describe('using a single matcher', () => { + let next: NextInstance + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/[...route].js': ` + export default function Page({ message }) { + return
+

root page

+

{message}

+
+ } + + export const getServerSideProps = ({ params }) => { + return { + props: { + message: "Hello from /" + params.route.join("/") + } + } + } + `, + 'middleware.js': ` + import { NextResponse } from 'next/server' + export const config = { + matcher: '/middleware/works' + }; + export default (req) => { + const res = NextResponse.next(); + res.headers.set('X-From-Middleware', 'true'); + return res; + } + `, + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('does not add the header for root request', async () => { + const response = await fetchViaHTTP(next.url, '/') + expect(response.headers.get('X-From-Middleware')).toBeFalsy() + }) + + it('does not add the header for root data request', async () => { + const response = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/index.json`, + undefined, + { headers: { 'x-nextjs-data': '1' } } + ) + expect(response.headers.get('X-From-Middleware')).toBeFalsy() + }) + + it('adds the header for a matched path', async () => { + const response = await fetchViaHTTP(next.url, '/middleware/works') + expect(await response.text()).toContain('Hello from /middleware/works') + expect(response.headers.get('X-From-Middleware')).toBe('true') + }) + + it('adds the headers for a matched data path (with header)', async () => { + const response = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/middleware/works.json`, + undefined, + { headers: { 'x-nextjs-data': '1' } } + ) + expect(await response.json()).toMatchObject({ + pageProps: { + message: 'Hello from /middleware/works', + }, + }) + expect(response.headers.get('X-From-Middleware')).toBe('true') + }) + + it('adds the header for a matched data path (without header)', async () => { + const response = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/middleware/works.json` + ) + expect(await response.json()).toMatchObject({ + pageProps: { + message: 'Hello from /middleware/works', + }, + }) + expect(response.headers.get('X-From-Middleware')).toBe('true') + }) + + it('does not add the header for an unmatched path', async () => { + const response = await fetchViaHTTP(next.url, '/about/me') + expect(await response.text()).toContain('Hello from /about/me') + expect(response.headers.get('X-From-Middleware')).toBeNull() + }) +}) + +describe('using root matcher', () => { + let next: NextInstance + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/index.js': ` + export function getStaticProps() { + return { + props: { + message: 'hello world' + } + } + } + + export default function Home({ message }) { + return
Hi there!
+ } + `, + 'middleware.js': ` + import { NextResponse } from 'next/server' + export default (req) => { + const res = NextResponse.next(); + res.headers.set('X-From-Middleware', 'true'); + return res; + } + + export const config = { matcher: '/' }; + `, + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('adds the header to the /', async () => { + const response = await fetchViaHTTP(next.url, '/') + expect(response.status).toBe(200) + expect(Object.fromEntries(response.headers)).toMatchObject({ + 'x-from-middleware': 'true', + }) + }) + + it('adds the header to the /index', async () => { + const response = await fetchViaHTTP(next.url, '/index') + expect(Object.fromEntries(response.headers)).toMatchObject({ + 'x-from-middleware': 'true', + }) + }) + + it('adds the header for a matched data path (with header)', async () => { + const response = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/index.json`, + undefined, + { headers: { 'x-nextjs-data': '1' } } + ) + expect(await response.json()).toMatchObject({ + pageProps: { + message: 'hello world', + }, + }) + expect(response.headers.get('X-From-Middleware')).toBe('true') + }) + + it('adds the header for a matched data path (without header)', async () => { + const response = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/index.json` + ) + expect(await response.json()).toMatchObject({ + pageProps: { + message: 'hello world', + }, + }) + expect(response.headers.get('X-From-Middleware')).toBe('true') + }) +}) + +describe.each([ + { title: '' }, + { title: ' and trailingSlash', trailingSlash: true }, +])('using a single matcher with i18n$title', ({ trailingSlash }) => { + let next: NextInstance + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/index.js': ` + export default function Page({ message }) { + return
+

{message}

+
+ } + export const getServerSideProps = ({ params, locale }) => ({ + props: { message: \`(\${locale}) Hello from /\` } + }) + `, + 'pages/[...route].js': ` + export default function Page({ message }) { + return
+

catchall page

+

{message}

+
+ } + export const getServerSideProps = ({ params, locale }) => ({ + props: { message: \`(\${locale}) Hello from /\` + params.route.join("/") } + }) + `, + 'middleware.js': ` + import { NextResponse } from 'next/server' + export const config = { matcher: '/' }; + export default (req) => { + const res = NextResponse.next(); + res.headers.set('X-From-Middleware', 'true'); + return res; + } + `, + 'next.config.js': ` + module.exports = { + ${trailingSlash ? 'trailingSlash: true,' : ''} + i18n: { + localeDetection: false, + locales: ['es', 'en'], + defaultLocale: 'en', + } + } + `, + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it(`adds the header for a matched path`, async () => { + const res1 = await fetchViaHTTP(next.url, `/`) + expect(await res1.text()).toContain(`(en) Hello from /`) + expect(res1.headers.get('X-From-Middleware')).toBe('true') + const res2 = await fetchViaHTTP(next.url, `/es`) + expect(await res2.text()).toContain(`(es) Hello from /`) + expect(res2.headers.get('X-From-Middleware')).toBe('true') + }) + + it('adds the header for a mathed root path with /index', async () => { + const res1 = await fetchViaHTTP(next.url, `/index`) + expect(await res1.text()).toContain(`(en) Hello from /`) + expect(res1.headers.get('X-From-Middleware')).toBe('true') + const res2 = await fetchViaHTTP(next.url, `/es/index`) + expect(await res2.text()).toContain(`(es) Hello from /`) + expect(res2.headers.get('X-From-Middleware')).toBe('true') + }) + + it(`adds the headers for a matched data path`, async () => { + const res1 = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/en.json`, + undefined, + { headers: { 'x-nextjs-data': '1' } } + ) + expect(await res1.json()).toMatchObject({ + pageProps: { message: `(en) Hello from /` }, + }) + expect(res1.headers.get('X-From-Middleware')).toBe('true') + const res2 = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/es.json`, + undefined + ) + expect(await res2.json()).toMatchObject({ + pageProps: { message: `(es) Hello from /` }, + }) + expect(res2.headers.get('X-From-Middleware')).toBe('true') + }) + + it(`does not add the header for an unmatched path`, async () => { + const response = await fetchViaHTTP(next.url, `/about/me`) + expect(await response.text()).toContain('Hello from /about/me') + expect(response.headers.get('X-From-Middleware')).toBeNull() + }) +}) + +describe.each([ + { title: '' }, + { title: ' and trailingSlash', trailingSlash: true }, +])( + 'using a single matcher with i18n and basePath$title', + ({ trailingSlash }) => { + let next: NextInstance + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/index.js': ` + export default function Page({ message }) { + return
+

root page

+

{message}

+
+ } + export const getServerSideProps = ({ params, locale }) => ({ + props: { message: \`(\${locale}) Hello from /\` } + }) + `, + 'pages/[...route].js': ` + export default function Page({ message }) { + return
+

catchall page

+

{message}

+
+ } + export const getServerSideProps = ({ params, locale }) => ({ + props: { message: \`(\${locale}) Hello from /\` + params.route.join("/") } + }) + `, + 'middleware.js': ` + import { NextResponse } from 'next/server' + export const config = { matcher: '/' }; + export default (req) => { + const res = NextResponse.next(); + res.headers.set('X-From-Middleware', 'true'); + return res; + } + `, + 'next.config.js': ` + module.exports = { + ${trailingSlash ? 'trailingSlash: true,' : ''} + basePath: '/root', + i18n: { + localeDetection: false, + locales: ['es', 'en'], + defaultLocale: 'en', + } + } + `, + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it(`adds the header for a matched path`, async () => { + const res1 = await fetchViaHTTP(next.url, `/root`) + expect(await res1.text()).toContain(`(en) Hello from /`) + expect(res1.headers.get('X-From-Middleware')).toBe('true') + const res2 = await fetchViaHTTP(next.url, `/root/es`) + expect(await res2.text()).toContain(`(es) Hello from /`) + expect(res2.headers.get('X-From-Middleware')).toBe('true') + }) + + it('adds the header for a mathed root path with /index', async () => { + const res1 = await fetchViaHTTP(next.url, `/root/index`) + expect(await res1.text()).toContain(`(en) Hello from /`) + expect(res1.headers.get('X-From-Middleware')).toBe('true') + const res2 = await fetchViaHTTP(next.url, `/root/es/index`) + expect(await res2.text()).toContain(`(es) Hello from /`) + expect(res2.headers.get('X-From-Middleware')).toBe('true') + }) + + it(`adds the headers for a matched data path`, async () => { + const res1 = await fetchViaHTTP( + next.url, + `/root/_next/data/${next.buildId}/en.json`, + undefined, + { headers: { 'x-nextjs-data': '1' } } + ) + expect(await res1.json()).toMatchObject({ + pageProps: { message: `(en) Hello from /` }, + }) + expect(res1.headers.get('X-From-Middleware')).toBe('true') + const res2 = await fetchViaHTTP( + next.url, + `/root/_next/data/${next.buildId}/es.json`, + undefined, + { headers: { 'x-nextjs-data': '1' } } + ) + expect(await res2.json()).toMatchObject({ + pageProps: { message: `(es) Hello from /` }, + }) + expect(res2.headers.get('X-From-Middleware')).toBe('true') + }) + + it(`does not add the header for an unmatched path`, async () => { + const response = await fetchViaHTTP(next.url, `/root/about/me`) + expect(await response.text()).toContain('Hello from /about/me') + expect(response.headers.get('X-From-Middleware')).toBeNull() + }) + } +) diff --git a/test/e2e/tests/middleware-responses/app/middleware.js b/test/e2e/tests/middleware-responses/app/middleware.js new file mode 100644 index 0000000000..abc81bbe91 --- /dev/null +++ b/test/e2e/tests/middleware-responses/app/middleware.js @@ -0,0 +1,74 @@ +import { NextResponse } from 'next/server' + +// we use this trick to fool static analysis at build time, so we can build a +// middleware that will return a body at run time, and check it is disallowed. +class MyResponse extends Response {} + +export async function middleware(request, ev) { + // eslint-disable-next-line no-undef + const { readable, writable } = new TransformStream() + const url = request.nextUrl + const writer = writable.getWriter() + const encoder = new TextEncoder() + const next = NextResponse.next() + + // this is needed for tests to get the BUILD_ID + if (url.pathname.startsWith('/_next/static/__BUILD_ID')) { + return NextResponse.next() + } + + // Header based on query param + if (url.searchParams.get('nested-header') === 'true') { + next.headers.set('x-nested-header', 'valid') + } + + // Ensure deep can append to this value + if (url.searchParams.get('append-me') === 'true') { + next.headers.append('x-append-me', 'top') + } + + // Ensure deep can append to this value + if (url.searchParams.get('cookie-me') === 'true') { + next.headers.append('set-cookie', 'bar=chocochip') + } + + // Sends a header + if (url.pathname === '/header') { + next.headers.set('x-first-header', 'valid') + return next + } + + if (url.pathname === '/two-cookies') { + const headers = new Headers() + headers.append('set-cookie', 'foo=chocochip') + headers.append('set-cookie', 'bar=chocochip') + return new Response(null, { headers }) + } + + // Streams a basic response + if (url.pathname === '/stream-a-response') { + ev.waitUntil( + (async () => { + writer.write(encoder.encode('this is a streamed ')) + writer.write(encoder.encode('response ')) + writer.close() + })() + ) + + return new MyResponse(readable) + } + + if (url.pathname === '/bad-status') { + return new Response(null, { + headers: { 'WWW-Authenticate': 'Basic realm="Secure Area"' }, + status: 401, + }) + } + + // Sends response + if (url.pathname === '/send-response') { + return new MyResponse(JSON.stringify({ message: 'hi!' })) + } + + return next +} diff --git a/test/e2e/tests/middleware-responses/app/next.config.js b/test/e2e/tests/middleware-responses/app/next.config.js new file mode 100644 index 0000000000..f548199a3b --- /dev/null +++ b/test/e2e/tests/middleware-responses/app/next.config.js @@ -0,0 +1,6 @@ +module.exports = { + i18n: { + locales: ['en', 'fr', 'nl'], + defaultLocale: 'en', + }, +} diff --git a/test/e2e/tests/middleware-responses/app/pages/index.js b/test/e2e/tests/middleware-responses/app/pages/index.js new file mode 100644 index 0000000000..e931b33f81 --- /dev/null +++ b/test/e2e/tests/middleware-responses/app/pages/index.js @@ -0,0 +1,48 @@ +import Link from 'next/link' + +export default function Home({ message }) { + return ( +
+

Hello {message}

+ Stream a response +
+ Stream a long response + Test streaming after response ends +
+ + Attempt to add a header after stream ends + +
+ + Redirect to Google and attempt to stream after + +
+ Respond with a header +
+ + Respond with 2 headers (nested middleware effect) + +
+ Respond with body, end, set a header +
+ + Respond with body, end, send another body + +
+ Respond with body +
+ Redirect and then send a body +
+ Send React component as a body +
+ Stream React component +
+ 404 +
+
+ ) +} + +export const getServerSideProps = ({ query }) => ({ + props: { message: query.message || 'World' }, +}) diff --git a/test/e2e/tests/middleware-responses/test/index.test.ts b/test/e2e/tests/middleware-responses/test/index.test.ts new file mode 100644 index 0000000000..6fd4bcd962 --- /dev/null +++ b/test/e2e/tests/middleware-responses/test/index.test.ts @@ -0,0 +1,89 @@ +/* eslint-env jest */ + +import { join } from 'path' +import { fetchViaHTTP } from 'next-test-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { createNext, FileRef } from 'e2e-utils' + +describe('Middleware Responses', () => { + let next: NextInstance + + afterAll(() => next.destroy()) + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, '../app/pages')), + 'middleware.js': new FileRef(join(__dirname, '../app/middleware.js')), + 'next.config.js': new FileRef(join(__dirname, '../app/next.config.js')), + }, + }) + }) + function testsWithLocale(locale = '') { + const label = locale ? `${locale} ` : `` + + it(`${label}responds with multiple cookies`, async () => { + const res = await fetchViaHTTP(next.url, `${locale}/two-cookies`) + expect(res.headers.raw()['set-cookie']).toEqual([ + 'foo=chocochip', + 'bar=chocochip', + ]) + }) + + it(`${label}should fail when returning a stream`, async () => { + const res = await fetchViaHTTP(next.url, `${locale}/stream-a-response`) + expect(res.status).toBe(500) + + if (!(global as any).isNextDeploy) { + expect(await res.text()).toEqual('Internal Server Error') + expect(next.cliOutput).toContain( + `A middleware can not alter response's body. Learn more: https://nextjs.org/docs/messages/returning-response-body-in-middleware` + ) + } + }) + + it(`${label}should fail when returning a text body`, async () => { + const res = await fetchViaHTTP(next.url, `${locale}/send-response`) + expect(res.status).toBe(500) + + if (!(global as any).isNextDeploy) { + expect(await res.text()).toEqual('Internal Server Error') + expect(next.cliOutput).toContain( + `A middleware can not alter response's body. Learn more: https://nextjs.org/docs/messages/returning-response-body-in-middleware` + ) + } + }) + + it(`${label}should respond with a 401 status code`, async () => { + const res = await fetchViaHTTP(next.url, `${locale}/bad-status`) + const html = await res.text() + expect(res.status).toBe(401) + expect(html).toBe('') + }) + + it(`${label}should respond with one header`, async () => { + const res = await fetchViaHTTP(next.url, `${locale}/header`) + expect(res.headers.get('x-first-header')).toBe('valid') + }) + + it(`${label}should respond with two headers`, async () => { + const res = await fetchViaHTTP( + next.url, + `${locale}/header?nested-header=true` + ) + expect(res.headers.get('x-first-header')).toBe('valid') + expect(res.headers.get('x-nested-header')).toBe('valid') + }) + + it(`${label}should respond appending headers headers`, async () => { + const res = await fetchViaHTTP( + next.url, + `${locale}/?nested-header=true&append-me=true&cookie-me=true` + ) + expect(res.headers.get('x-nested-header')).toBe('valid') + expect(res.headers.get('x-append-me')).toBe('top') + expect(res.headers.raw()['set-cookie']).toEqual(['bar=chocochip']) + }) + } + testsWithLocale() + testsWithLocale('/fr') +}) diff --git a/test/e2e/tests/og-api/app/next.config.js b/test/e2e/tests/og-api/app/next.config.js new file mode 100644 index 0000000000..b6b4bc39ee --- /dev/null +++ b/test/e2e/tests/og-api/app/next.config.js @@ -0,0 +1,4 @@ +/** @type {import('next').NextConfig} */ +module.exports = { + output: 'standalone', +} diff --git a/test/e2e/tests/og-api/app/pages/api/og.js b/test/e2e/tests/og-api/app/pages/api/og.js new file mode 100644 index 0000000000..1afb24a112 --- /dev/null +++ b/test/e2e/tests/og-api/app/pages/api/og.js @@ -0,0 +1,26 @@ +// /pages/api/og.jsx +import { ImageResponse } from '@vercel/og' + +export const config = { + runtime: 'experimental-edge', +} + +export default function () { + return new ImageResponse( + ( +
+ Hello! +
+ ) + ) +} diff --git a/test/e2e/tests/og-api/app/pages/index.js b/test/e2e/tests/og-api/app/pages/index.js new file mode 100644 index 0000000000..ff7159d914 --- /dev/null +++ b/test/e2e/tests/og-api/app/pages/index.js @@ -0,0 +1,3 @@ +export default function Page() { + return

hello world

+} diff --git a/test/e2e/tests/og-api/index.test.ts b/test/e2e/tests/og-api/index.test.ts new file mode 100644 index 0000000000..9da3b614ce --- /dev/null +++ b/test/e2e/tests/og-api/index.test.ts @@ -0,0 +1,30 @@ +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { fetchViaHTTP, renderViaHTTP } from 'next-test-utils' +import { join } from 'path' + +describe('og-api', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: new FileRef(join(__dirname, 'app')), + dependencies: { + '@vercel/og': 'latest', + }, + }) + }) + afterAll(() => next.destroy()) + + it('should respond from index', async () => { + const html = await renderViaHTTP(next.url, '/') + expect(html).toContain('hello world') + }) + + it('should work', async () => { + const res = await fetchViaHTTP(next.url, '/api/og') + expect(res.status).toBe(200) + const body = await res.blob() + expect(body.size).toBeGreaterThan(0) + }) +}) diff --git a/test/e2e/tests/ssr-react-context/app/context.js b/test/e2e/tests/ssr-react-context/app/context.js new file mode 100644 index 0000000000..d50a243270 --- /dev/null +++ b/test/e2e/tests/ssr-react-context/app/context.js @@ -0,0 +1,5 @@ +import React from 'react' + +export const Idk = React.createContext(null) + +export const useIdk = () => React.useContext(Idk) diff --git a/test/e2e/tests/ssr-react-context/app/pages/_app.js b/test/e2e/tests/ssr-react-context/app/pages/_app.js new file mode 100644 index 0000000000..5863cbabb2 --- /dev/null +++ b/test/e2e/tests/ssr-react-context/app/pages/_app.js @@ -0,0 +1,9 @@ +import { Idk } from '../context' + +export default function MyApp({ Component, pageProps }) { + return ( + + + + ) +} diff --git a/test/e2e/tests/ssr-react-context/app/pages/consumer.js b/test/e2e/tests/ssr-react-context/app/pages/consumer.js new file mode 100644 index 0000000000..ab8100bff2 --- /dev/null +++ b/test/e2e/tests/ssr-react-context/app/pages/consumer.js @@ -0,0 +1,17 @@ +import React from 'react' + +const NumberContext = React.createContext(0) + +export default function page() { + return ( + + + {(value) =>

Value: {value}

} +
+
+ ) +} + +export async function getServerSideProps() { + return { props: {} } +} diff --git a/test/e2e/tests/ssr-react-context/app/pages/index.js b/test/e2e/tests/ssr-react-context/app/pages/index.js new file mode 100644 index 0000000000..2c10108fc5 --- /dev/null +++ b/test/e2e/tests/ssr-react-context/app/pages/index.js @@ -0,0 +1,16 @@ +import React from 'react' +import { useIdk } from '../context' + +const Page = () => { + const idk = useIdk() + + console.log(idk) + + return ( + <> +

Value: {idk}

+ + ) +} + +export default Page diff --git a/test/e2e/tests/ssr-react-context/index.test.ts b/test/e2e/tests/ssr-react-context/index.test.ts new file mode 100644 index 0000000000..503fe7f852 --- /dev/null +++ b/test/e2e/tests/ssr-react-context/index.test.ts @@ -0,0 +1,46 @@ +import { join } from 'path' +import { renderViaHTTP, check } from 'next-test-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { createNext, FileRef } from 'e2e-utils' + +describe('React Context', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, 'app/pages')), + 'context.js': new FileRef(join(__dirname, 'app/context.js')), + }, + }) + }) + afterAll(() => next.destroy()) + + it('should render a page with context', async () => { + const html = await renderViaHTTP(next.url, '/') + expect(html).toMatch(/Value: .*?hello world/) + }) + + it('should render correctly with context consumer', async () => { + const html = await renderViaHTTP(next.url, '/consumer') + expect(html).toMatch(/Value: .*?12345/) + }) + + if ((globalThis as any).isNextDev) { + it('should render with context after change', async () => { + const aboutAppPagePath = 'pages/_app.js' + const originalContent = await next.readFile(aboutAppPagePath) + await next.patchFile( + aboutAppPagePath, + originalContent.replace('hello world', 'new value') + ) + + try { + await check(() => renderViaHTTP(next.url, '/'), /Value: .*?new value/) + } finally { + await next.patchFile(aboutAppPagePath, originalContent) + } + await check(() => renderViaHTTP(next.url, '/'), /Value: .*?hello world/) + }) + } +}) diff --git a/test/e2e/tests/styled-jsx/app/.npmrc b/test/e2e/tests/styled-jsx/app/.npmrc new file mode 100644 index 0000000000..4c2f52b3be --- /dev/null +++ b/test/e2e/tests/styled-jsx/app/.npmrc @@ -0,0 +1,2 @@ +auto-install-peers=true +strict-peer-dependencies=false diff --git a/test/e2e/tests/styled-jsx/app/node_modules_bak/my-comps/button.js b/test/e2e/tests/styled-jsx/app/node_modules_bak/my-comps/button.js new file mode 100644 index 0000000000..a800c06432 --- /dev/null +++ b/test/e2e/tests/styled-jsx/app/node_modules_bak/my-comps/button.js @@ -0,0 +1,31 @@ +Object.defineProperty(exports, '__esModule', { value: true }) + +function _interopDefault(ex) { + return ex && typeof ex === 'object' && 'default' in ex ? ex['default'] : ex +} + +var _JSXStyle = _interopDefault(require('styled-jsx/style')) +var React = require('react') + +function Button() { + return React.createElement( + 'button', + { + className: 'jsx-451104437', + }, + 'hello', + React.createElement( + _JSXStyle, + { + id: '451104437', + }, + '.jsx-451104437{color:cyan;}\n/*# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIkJ1dHRvbi50c3giXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBTWtCLEFBRW1CLFVBQ1oiLCJmaWxlIjoiQnV0dG9uLnRzeCIsInNvdXJjZXNDb250ZW50IjpbImltcG9ydCAqIGFzIFJlYWN0IGZyb20gJ3JlYWN0JztcblxuZXhwb3J0IGRlZmF1bHQgZnVuY3Rpb24gQnV0dG9uKCkge1xuICByZXR1cm4gKFxuICAgIDxidXR0b24+XG4gICAgICBoZWxsb1xuICAgICAgPHN0eWxlIGpzeD57YFxuICAgICAgICBjb2xybzogcmVkO1xuICAgICAgYH08L3N0eWxlPlxuICAgIDwvYnV0dG9uPlxuICApO1xufVxuIl19 */\n/*@ sourceURL=Button.tsx */' + ) + ) +} + +/** + * @class ExampleComponent + */ + +exports.Button = Button diff --git a/test/e2e/tests/styled-jsx/app/pages/amp.js b/test/e2e/tests/styled-jsx/app/pages/amp.js new file mode 100644 index 0000000000..8f71fdbc5c --- /dev/null +++ b/test/e2e/tests/styled-jsx/app/pages/amp.js @@ -0,0 +1,12 @@ +import { Button } from 'my-comps/button' + +export const config = { amp: true } + +export default function page() { + return ( + <> +

Hello world

+ + + ) +} diff --git a/test/e2e/tests/styled-jsx/app/pages/index.js b/test/e2e/tests/styled-jsx/app/pages/index.js new file mode 100644 index 0000000000..378661135f --- /dev/null +++ b/test/e2e/tests/styled-jsx/app/pages/index.js @@ -0,0 +1,11 @@ +import { Button } from 'my-comps/button' + +export default function Page() { + return ( +
+ +

hello world

+ +
+ ) +} diff --git a/test/e2e/tests/styled-jsx/index.test.ts b/test/e2e/tests/styled-jsx/index.test.ts new file mode 100644 index 0000000000..aa22928ece --- /dev/null +++ b/test/e2e/tests/styled-jsx/index.test.ts @@ -0,0 +1,68 @@ +import path from 'path' +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { renderViaHTTP } from 'next-test-utils' +import webdriver from 'next-webdriver' + +const appDir = path.join(__dirname, 'app') + +function runTest() { + describe(`styled-jsx`, () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + node_modules_bak: new FileRef(path.join(appDir, 'node_modules_bak')), + pages: new FileRef(path.join(appDir, 'pages')), + '.npmrc': new FileRef(path.join(appDir, '.npmrc')), + }, + packageJson: { + scripts: { + setup: `cp -r ./node_modules_bak/my-comps ./node_modules;`, + build: `yarn setup && next build`, + dev: `yarn setup && next dev`, + start: 'next start', + }, + }, + dependencies: { + 'styled-jsx': '5.0.0', // styled-jsx on user side + }, + startCommand: 'yarn ' + ((global as any).isNextDev ? 'dev' : 'start'), + buildCommand: `yarn build`, + }) + }) + afterAll(() => next.destroy()) + + it('should contain styled-jsx styles during SSR', async () => { + const html = await renderViaHTTP(next.url, '/') + expect(html).toMatch(/color:.*?red/) + expect(html).toMatch(/color:.*?cyan/) + }) + + it('should render styles during CSR', async () => { + const browser = await webdriver(next.url, '/') + const color = await browser.eval( + `getComputedStyle(document.querySelector('button')).color` + ) + + expect(color).toMatch('0, 255, 255') + }) + + it('should render styles during CSR (AMP)', async () => { + const browser = await webdriver(next.url, '/amp') + const color = await browser.eval( + `getComputedStyle(document.querySelector('button')).color` + ) + + expect(color).toMatch('0, 255, 255') + }) + + it('should render styles during SSR (AMP)', async () => { + const html = await renderViaHTTP(next.url, '/amp') + expect(html).toMatch(/color:.*?cyan/) + }) + }) +} + +runTest() diff --git a/test/e2e/tests/type-module-interop/index.test.ts b/test/e2e/tests/type-module-interop/index.test.ts new file mode 100644 index 0000000000..5e1043d7ca --- /dev/null +++ b/test/e2e/tests/type-module-interop/index.test.ts @@ -0,0 +1,113 @@ +import { createNext } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { hasRedbox, renderViaHTTP } from 'next-test-utils' +import webdriver from 'next-webdriver' +import cheerio from 'cheerio' + +describe('Type module interop', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/index.js': ` + import Link from 'next/link' + import Head from 'next/head' + import Script from 'next/script' + import dynamic from 'next/dynamic' + import { useAmp } from 'next/amp' + + const Dynamic = dynamic(() => import('../components/example')) + + export default function Page() { + const isAmp = useAmp() + return ( + <> + + This page has a title 🤔 + + + +