diff --git a/.eslintrc.js b/.eslintrc.js index 9f4e641bee..d9075ea9a1 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -34,5 +34,16 @@ module.exports = { env: { jest: true, }, - overrides: [...overrides], + overrides: [ + ...overrides, + { + files: ['cypress/**/*.spec.ts'], + rules: { + 'max-nested-callbacks': 0, + 'promise/prefer-await-to-then': 0, + 'promise/always-return': 0, + 'promise/catch-or-return': 0, + }, + }, + ], } diff --git a/cypress/integration/custom-errors.spec.ts b/cypress/integration/custom-errors.spec.ts new file mode 100644 index 0000000000..03478774de --- /dev/null +++ b/cypress/integration/custom-errors.spec.ts @@ -0,0 +1,14 @@ +/** + * @see {@link https://nextjs.org/docs/advanced-features/custom-error-page} + */ + describe('Custom error pages', () => { + it('should show custom 404 page on /404', () => { + cy.visit('/404', {failOnStatusCode: false}) + cy.findByText('Custom 404 - Page Not Found') + }) + + it('should show custom 500 page on /500', () => { + cy.visit('/500', {failOnStatusCode: false}) + cy.findByText('Custom 500 - Server-side error occurred') + }) +}) \ No newline at end of file diff --git a/cypress/integration/default.spec.ts b/cypress/integration/default.spec.ts new file mode 100644 index 0000000000..28c1c1cdd0 --- /dev/null +++ b/cypress/integration/default.spec.ts @@ -0,0 +1,24 @@ +describe('Default site', () => { + beforeEach(() => { + cy.visit('/') + }) + + it('loads home page', () => { + cy.findByText('Next Demo!') + cy.findByTestId("list-server-side").within(() => { + cy.findAllByRole("link").should('have.length', 5) + }) + + cy.findByTestId("list-dynamic-pages").within(() => { + cy.findAllByRole("link").should('have.length', 3) + }) + + cy.findByTestId("list-catch-all").within(() => { + cy.findAllByRole("link").should('have.length', 3) + }) + + cy.findByTestId("list-static").within(() => { + cy.findAllByRole("link").should('have.length', 2) + }) + }) +}) \ No newline at end of file diff --git a/cypress/integration/dynamic-routes.spec.ts b/cypress/integration/dynamic-routes.spec.ts new file mode 100644 index 0000000000..a0cf095942 --- /dev/null +++ b/cypress/integration/dynamic-routes.spec.ts @@ -0,0 +1,7 @@ +describe('Dynamic Routing', () => { + it('loads page', () => { + cy.visit('/shows/250') + cy.findByRole('heading').should('contain', '250') + cy.findByText('Kirby Buckets') + }) +}) \ No newline at end of file diff --git a/cypress/integration/headers.spec.ts b/cypress/integration/headers.spec.ts new file mode 100644 index 0000000000..2b5551ecae --- /dev/null +++ b/cypress/integration/headers.spec.ts @@ -0,0 +1,9 @@ +describe('Headers', () => { + it('should set headers from the next.config.js', () => { + cy.request({ + url: '/', + }).then((response) => { + expect(response.headers).to.have.property('x-custom-header', 'my custom header value') + }) + }) +}) \ No newline at end of file diff --git a/cypress/integration/i18n.spec.ts b/cypress/integration/i18n.spec.ts new file mode 100644 index 0000000000..ffe5291943 --- /dev/null +++ b/cypress/integration/i18n.spec.ts @@ -0,0 +1,38 @@ +describe('Localization', () => { + it('should use sub routing to determine current locale', () => { + cy.visit('/'); + + cy.findByText('The current locale is en') + + cy.visit('/fr') + cy.findByText('The current locale is fr') + }) + + it('should use the NEXT_LOCALE cookie to determine the default locale', () => { + cy.setCookie('NEXT_LOCALE', 'fr') + cy.visit('/'); + + cy.findByText('The current locale is fr') + }) + + it('should use the NEXT_LOCALE cookie over Accept-Language header to determine the default locale', () => { + // cy.setCookie('NEXT_LOCALE', 'en') + cy.visit({ + url: '/', + headers: { + 'Accept-Language': 'fr;q=0.9' + } + }); + cy.findByText('The current locale is fr') + + cy.setCookie('NEXT_LOCALE', 'en') + cy.visit({ + url: '/', + headers: { + 'Accept-Language': 'fr;q=0.9' + } + }); + + cy.findByText('The current locale is en') + }) +}) \ No newline at end of file diff --git a/cypress/integration/images.spec.ts b/cypress/integration/images.spec.ts new file mode 100644 index 0000000000..0d912d33a8 --- /dev/null +++ b/cypress/integration/images.spec.ts @@ -0,0 +1,20 @@ +/** + * @see {@link https://nextjs.org/docs/api-reference/next/image#required-props} + */ +describe('next/images', () => { + it('should show static image from /public', () => { + cy.visit('/') + cy.findByRole('img').should('be.visible').and(($img) => { + // "naturalWidth" and "naturalHeight" are set when the image loads + expect( + $img[0].naturalWidth, + 'image has natural width' + ).to.be.greaterThan(0) + }) + }) + + it('should show image using next/image', () => { + cy.visit('/image') + cy.findByRole('img', { name: /shiba inu dog looks through a window/i }) + }) +}) \ No newline at end of file diff --git a/cypress/integration/preview.spec.ts b/cypress/integration/preview.spec.ts new file mode 100644 index 0000000000..39d1b66574 --- /dev/null +++ b/cypress/integration/preview.spec.ts @@ -0,0 +1,21 @@ +describe('Preview Mode', () => { + it('enters and exits preview mode', () => { + // preview mode is off by default + cy.visit('/previewTest') + cy.findByText('Number: 4') + + // enter preview mode + cy.request('/api/enterPreview').then( + (response) => { + expect(response.body).to.have.property('name', 'preview mode') + } +) + cy.visit('/previewTest') + cy.findByText('Number: 3') + + // exit preview mode + cy.request('/api/exitPreview') + cy.visit('/previewTest') + cy.findByText('Number: 4') + }) +}) \ No newline at end of file diff --git a/cypress/integration/rewrites-redirects.spec.ts b/cypress/integration/rewrites-redirects.spec.ts new file mode 100644 index 0000000000..bf62ef27b3 --- /dev/null +++ b/cypress/integration/rewrites-redirects.spec.ts @@ -0,0 +1,21 @@ +describe('Rewrites and Redirects', () => { + it('rewrites: points /old to /', () => { + // preview mode is off by default + cy.visit('/old') + cy.findByText('Next Demo!') + cy.url().should('eq', `${Cypress.config().baseUrl}/old/`) + + // ensure headers are still set + cy.request('/api/enterPreview').then( + (response) => { + expect(response.body).to.have.property('name', 'preview mode') + } +) + }) + + it('redirects: redirects /redirectme to /', () => { + cy.visit('/redirectme') + cy.url().should('eq', `${Cypress.config().baseUrl}/`) + } + ) +}) \ No newline at end of file diff --git a/cypress/integration/trailing-slash.spec.ts b/cypress/integration/trailing-slash.spec.ts new file mode 100644 index 0000000000..e06e7dd982 --- /dev/null +++ b/cypress/integration/trailing-slash.spec.ts @@ -0,0 +1,11 @@ +describe('Trailing slash enabled', () => { + it('should keep the trailing slash, i.e. points /old/ to /old/', () => { + cy.visit('/old/') + cy.url().should('eq', `${Cypress.config().baseUrl}/old/`) + }) + + it('should put a trailing slash when there is none, i.e. points /old to /old/', () => { + cy.visit('/old') + cy.url().should('eq', `${Cypress.config().baseUrl}/old/`) + }) +}) \ No newline at end of file diff --git a/demo/next.config.js b/demo/next.config.js index 3b53233b90..93c2cbab23 100644 --- a/demo/next.config.js +++ b/demo/next.config.js @@ -6,9 +6,23 @@ module.exports = { defaultLocale: 'en', locales: ['en', 'es', 'fr'] }, - // trailingSlash: true, + async headers() { + return [ + { + source: '/', + headers: [ + { + key: 'x-custom-header', + value: 'my custom header value', + } + ], + }, + ] + }, + trailingSlash: true, // Configurable site features _to_ support: // basePath: '/docs', + // Rewrites allow you to map an incoming request path to a different destination path. async rewrites() { return { beforeFiles: [ @@ -18,5 +32,15 @@ module.exports = { } ] } - } + }, + // Redirects allow you to redirect an incoming request path to a different destination path. + async redirects() { + return [ + { + source: '/redirectme', + destination: '/', + permanent: true, + }, + ] + }, } diff --git a/demo/pages/index.js b/demo/pages/index.js index d1225acb43..d7e4b6ac2c 100644 --- a/demo/pages/index.js +++ b/demo/pages/index.js @@ -1,128 +1,156 @@ import Link from 'next/link' import dynamic from 'next/dynamic' const Header = dynamic(() => import(/* webpackChunkName: 'header' */ '../components/Header'), { ssr: true }) +import { useRouter } from 'next/router' -const Index = ({ shows }) => ( -
- NextJS on Netlify Banner - -
- -

NextJS on Netlify

-

- This is a demo of a NextJS application with Server-Side Rendering (SSR). -
- It is hosted on Netlify. -
- Server-side rendering is handled by Netlify Functions. -
- Minimal configuration is required. -
- Everything is handled by the next-on-netlify npm - package. -

- -

1. Server-Side Rendering Made Easy

-

- This page is server-side rendered. -
- It fetches a random list of five TV shows from the TVmaze REST API. -
- Refresh this page to see it change. -

- - + +

5. Localization As Expected

+

+ Localization (i18n) is supported! This demo uses fr with en as the default locale (at /). +

+ The current locale is {locale} +

Click on the links below to see the above text change

+ + +

Want to Learn More?

+

+ Check out the source code on GitHub. +

+
+ ) +} Index.getInitialProps = async function () { + const dev = process.env.CONTEXT !== 'production'; + // Set a random page between 1 and 100 const randomPage = Math.floor(Math.random() * 100) + 1 + // FIXME: stub out in dev + const server = dev ? `https://api.tvmaze.com/shows?page=${randomPage}` : `https://api.tvmaze.com/shows?page=${randomPage}`; // Get the data - const res = await fetch(`https://api.tvmaze.com/shows?page=${randomPage}`) + const res = await fetch(server); const data = await res.json() return { shows: data.slice(0, 5) } diff --git a/demo/public/shows1.json b/demo/public/shows1.json new file mode 100644 index 0000000000..4755d47621 --- /dev/null +++ b/demo/public/shows1.json @@ -0,0 +1,272 @@ +[{ + "id": 250, + "url": "https://www.tvmaze.com/shows/250/kirby-buckets", + "name": "Kirby Buckets", + "type": "Scripted", + "language": "English", + "genres": ["Comedy"], + "status": "Ended", + "runtime": 30, + "averageRuntime": 30, + "premiered": "2014-10-20", + "ended": "2017-02-02", + "officialSite": "http://disneyxd.disney.com/kirby-buckets", + "schedule": { + "time": "07:00", + "days": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"] + }, + "rating": { + "average": null + }, + "weight": 69, + "network": { + "id": 25, + "name": "Disney XD", + "country": { + "name": "United States", + "code": "US", + "timezone": "America/New_York" + } + }, + "webChannel": { + "id": 83, + "name": "DisneyNOW", + "country": { + "name": "United States", + "code": "US", + "timezone": "America/New_York" + } + }, + "dvdCountry": null, + "externals": { + "tvrage": 37394, + "thetvdb": 278449, + "imdb": "tt3544772" + }, + "image": { + "medium": "https://static.tvmaze.com/uploads/images/medium_portrait/1/4600.jpg", + "original": "https://static.tvmaze.com/uploads/images/original_untouched/1/4600.jpg" + }, + "summary": "

The single-camera series that mixes live-action and animation stars Jacob Bertrand as the title character. Kirby Buckets introduces viewers to the vivid imagination of charismatic 13-year-old Kirby Buckets, who dreams of becoming a famous animator like his idol, Mac MacCallister. With his two best friends, Fish and Eli, by his side, Kirby navigates his eccentric town of Forest Hills where the trio usually find themselves trying to get out of a predicament before Kirby's sister, Dawn, and her best friend, Belinda, catch them. Along the way, Kirby is joined by his animated characters, each with their own vibrant personality that only he and viewers can see.

", + "updated": 1617744408, + "_links": { + "self": { + "href": "https://api.tvmaze.com/shows/250" + }, + "previousepisode": { + "href": "https://api.tvmaze.com/episodes/1051658" + } + } +}, { + "id": 251, + "url": "https://www.tvmaze.com/shows/251/downton-abbey", + "name": "Downton Abbey", + "type": "Scripted", + "language": "English", + "genres": ["Drama", "Family", "Romance"], + "status": "Ended", + "runtime": 60, + "averageRuntime": 71, + "premiered": "2010-09-26", + "ended": "2015-12-25", + "officialSite": "http://www.itv.com/downtonabbey", + "schedule": { + "time": "21:00", + "days": ["Sunday"] + }, + "rating": { + "average": 9.1 + }, + "weight": 96, + "network": { + "id": 35, + "name": "ITV", + "country": { + "name": "United Kingdom", + "code": "GB", + "timezone": "Europe/London" + } + }, + "webChannel": null, + "dvdCountry": null, + "externals": { + "tvrage": 26615, + "thetvdb": 193131, + "imdb": "tt1606375" + }, + "image": { + "medium": "https://static.tvmaze.com/uploads/images/medium_portrait/1/4601.jpg", + "original": "https://static.tvmaze.com/uploads/images/original_untouched/1/4601.jpg" + }, + "summary": "

The Downton Abbey estate stands a splendid example of confidence and mettle, its family enduring for generations and its staff a well-oiled machine of propriety. But change is afoot at Downton--change far surpassing the new electric lights and telephone. A crisis of inheritance threatens to displace the resident Crawley family, in spite of the best efforts of the noble and compassionate Earl, Robert Crawley; his American heiress wife, Cora his comically implacable, opinionated mother, Violet and his beautiful, eldest daughter, Mary, intent on charting her own course. Reluctantly, the family is forced to welcome its heir apparent, the self-made and proudly modern Matthew Crawley himself none too happy about the new arrangements. As Matthew's bristly relationship with Mary begins to crackle with electricity, hope for the future of Downton's dynasty takes shape. But when petty jealousies and ambitions grow among the family and the staff, scheming and secrets--both delicious and dangerous--threaten to derail the scramble to preserve Downton Abbey. Downton Abbey offers a spot-on portrait of a vanishing way of life.

", + "updated": 1627415536, + "_links": { + "self": { + "href": "https://api.tvmaze.com/shows/251" + }, + "previousepisode": { + "href": "https://api.tvmaze.com/episodes/623237" + } + } +}, { + "id": 252, + "url": "https://www.tvmaze.com/shows/252/girl-meets-world", + "name": "Girl Meets World", + "type": "Scripted", + "language": "English", + "genres": ["Drama", "Comedy", "Family"], + "status": "Ended", + "runtime": 30, + "averageRuntime": 30, + "premiered": "2014-06-27", + "ended": "2017-01-20", + "officialSite": "http://disneychannel.disney.com/girl-meets-world", + "schedule": { + "time": "18:00", + "days": ["Friday"] + }, + "rating": { + "average": 7.7 + }, + "weight": 72, + "network": { + "id": 78, + "name": "Disney Channel", + "country": { + "name": "United States", + "code": "US", + "timezone": "America/New_York" + } + }, + "webChannel": { + "id": 83, + "name": "DisneyNOW", + "country": { + "name": "United States", + "code": "US", + "timezone": "America/New_York" + } + }, + "dvdCountry": null, + "externals": { + "tvrage": 33436, + "thetvdb": 267777, + "imdb": "tt2543796" + }, + "image": { + "medium": "https://static.tvmaze.com/uploads/images/medium_portrait/316/792450.jpg", + "original": "https://static.tvmaze.com/uploads/images/original_untouched/316/792450.jpg" + }, + "summary": "

Girl Meets World is based on ABC's hugely popular sitcom, Boy Meets World (1993). Set in New York City, the show tells the wonderfully funny heartfelt stories that Boy Meets World is renowned for - only this time from a tween girl's perspective - as the curious and bright 7th grader Riley Matthews and her quick-witted friend Maya Fox embark on an unforgettable middle school experience. But their plans for a carefree year will be adjusted slightly under the watchful eyes of Riley's parents - dad Cory, who's also a faculty member (and their new History teacher), and mom Topanga, who owns a trendy after school hangout that specializes in pudding.

", + "updated": 1621082326, + "_links": { + "self": { + "href": "https://api.tvmaze.com/shows/252" + }, + "previousepisode": { + "href": "https://api.tvmaze.com/episodes/1011244" + } + } +}, { + "id": 253, + "url": "https://www.tvmaze.com/shows/253/hells-kitchen", + "name": "Hell's Kitchen", + "type": "Reality", + "language": "English", + "genres": ["Food"], + "status": "Running", + "runtime": 60, + "averageRuntime": 60, + "premiered": "2005-05-30", + "ended": null, + "officialSite": "https://www.fox.com/hells-kitchen", + "schedule": { + "time": "20:00", + "days": ["Monday"] + }, + "rating": { + "average": 7.1 + }, + "weight": 93, + "network": { + "id": 4, + "name": "FOX", + "country": { + "name": "United States", + "code": "US", + "timezone": "America/New_York" + } + }, + "webChannel": null, + "dvdCountry": null, + "externals": { + "tvrage": 3828, + "thetvdb": 74897, + "imdb": "tt0437005" + }, + "image": { + "medium": "https://static.tvmaze.com/uploads/images/medium_portrait/324/811405.jpg", + "original": "https://static.tvmaze.com/uploads/images/original_untouched/324/811405.jpg" + }, + "summary": "

In Hell's Kitchen, aspiring chefs are put through an intense culinary academy to prove they possess the right combination of ingredients to win a life-changing grand prize.

", + "updated": 1631607953, + "_links": { + "self": { + "href": "https://api.tvmaze.com/shows/253" + }, + "previousepisode": { + "href": "https://api.tvmaze.com/episodes/2118484" + } + } +}, { + "id": 254, + "url": "https://www.tvmaze.com/shows/254/world-series-of-poker", + "name": "World Series of Poker", + "type": "Sports", + "language": "English", + "genres": [], + "status": "Running", + "runtime": 60, + "averageRuntime": 65, + "premiered": "2006-08-22", + "ended": null, + "officialSite": null, + "schedule": { + "time": "21:00", + "days": ["Monday", "Tuesday", "Sunday"] + }, + "rating": { + "average": 9 + }, + "weight": 77, + "network": { + "id": 180, + "name": "ESPN2", + "country": { + "name": "United States", + "code": "US", + "timezone": "America/New_York" + } + }, + "webChannel": null, + "dvdCountry": null, + "externals": { + "tvrage": 16764, + "thetvdb": 79028, + "imdb": "tt2733512" + }, + "image": { + "medium": "https://static.tvmaze.com/uploads/images/medium_portrait/1/4656.jpg", + "original": "https://static.tvmaze.com/uploads/images/original_untouched/1/4656.jpg" + }, + "summary": "

The World Series of Poker is where the world's best poker players battle for the title.

", + "updated": 1574298803, + "_links": { + "self": { + "href": "https://api.tvmaze.com/shows/254" + }, + "previousepisode": { + "href": "https://api.tvmaze.com/episodes/1684225" + } + } +}] diff --git a/test/__snapshots__/index.js.snap b/test/__snapshots__/index.js.snap index 5c693ff14e..05708675ff 100644 --- a/test/__snapshots__/index.js.snap +++ b/test/__snapshots__/index.js.snap @@ -127,7 +127,7 @@ Array [ exports[`onBuild() writes correct redirects to netlifyConfig 1`] = ` Array [ Object { - "from": "/_next/image*", + "from": "/_next/image/*", "query": Object { "q": ":quality", "url": ":url",