Skip to content

Commit

Permalink
Implement new core test API (vercel#44086)
Browse files Browse the repository at this point in the history
Adds a new way to write e2e tests for the Next.js core, mostly to reduce common boilerplate and make it easier to write tests for now team members.

```ts
// test/e2e/app-dir/head/head.test.ts
import { createNextDescribe } from 'e2e-utils'

createNextDescribe(
  'app dir head',
  {
    files: __dirname
  },
  ({ next }) => {
    test('handles ', async () => {
      // get cheerio (jQuery like API to traverse HTML)
      const $ = await next.render$('/')
      // get html
      const html = await next.render('/')
      // use fetch
      const res = await next.fetch('/')
      // get browser
      const browser = await next.browser('/')
    })
})
  • Loading branch information
timneutkens authored and jankaifer committed Dec 19, 2022
1 parent 79f56f0 commit f6ccccd
Show file tree
Hide file tree
Showing 3 changed files with 130 additions and 53 deletions.
62 changes: 16 additions & 46 deletions test/e2e/app-dir/head.test.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,21 @@
import fs from 'fs-extra'
import path from 'path'
import cheerio from 'cheerio'
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'
import { createNextDescribe } from 'e2e-utils'
import escapeStringRegexp from 'escape-string-regexp'

describe('app dir head', () => {
if ((global as any).isNextDeploy) {
it('should skip next deploy for now', () => {})
return
}

if (process.env.NEXT_TEST_REACT_VERSION === '^17') {
it('should skip for react v17', () => {})
return
}
let next: NextInstance

function runTests() {
beforeAll(async () => {
next = await createNext({
files: new FileRef(path.join(__dirname, 'head')),
dependencies: {
react: 'latest',
'react-dom': 'latest',
},
skipStart: true,
})

await next.start()
})
afterAll(() => next.destroy())

createNextDescribe(
'app dir head',
{
files: path.join(__dirname, 'head'),
skipDeployment: true,
},
({ next }) => {
it('should use head from index page', async () => {
const html = await renderViaHTTP(next.url, '/')
const $ = cheerio.load(html)
const $ = await next.render$('/')
const headTags = $('head').children().toArray()

// should not include default tags in page with head.js provided
expect(html).not.toContain(
expect($.html()).not.toContain(
'<meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/>'
)
expect(headTags.find((el) => el.attribs.src === '/hello.js')).toBeTruthy()
Expand All @@ -50,8 +25,7 @@ describe('app dir head', () => {
})

it('should use correct head for /blog', async () => {
const html = await renderViaHTTP(next.url, '/blog')
const $ = cheerio.load(html)
const $ = await next.render$('/blog')
const headTags = $('head').children().toArray()

expect(headTags.find((el) => el.attribs.src === '/hello3.js')).toBeFalsy()
Expand All @@ -67,8 +41,7 @@ describe('app dir head', () => {
})

it('should use head from layout when not on page', async () => {
const html = await renderViaHTTP(next.url, '/blog/about')
const $ = cheerio.load(html)
const $ = await next.render$('/blog/about')
const headTags = $('head').children().toArray()

expect(
Expand All @@ -83,8 +56,7 @@ describe('app dir head', () => {
})

it('should pass params to head for dynamic path', async () => {
const html = await renderViaHTTP(next.url, '/blog/post-1')
const $ = cheerio.load(html)
const $ = await next.render$('/blog/post-1')
const headTags = $('head').children().toArray()

expect(
Expand All @@ -100,7 +72,7 @@ describe('app dir head', () => {
})

it('should apply head when navigating client-side', async () => {
const browser = await webdriver(next.url, '/')
const browser = await next.browser('/')

const getTitle = () => browser.elementByCss('title').text()

Expand All @@ -125,7 +97,7 @@ describe('app dir head', () => {
next.on('stderr', (args) => {
errors.push(args)
})
const html = await renderViaHTTP(next.url, '/next-head')
const html = await next.render('/next-head')
expect(html).not.toMatch(/<title>legacy-head<\/title>/)

if (globalThis.isNextDev) {
Expand Down Expand Up @@ -155,6 +127,4 @@ describe('app dir head', () => {
}
})
}

runTests()
})
)
54 changes: 54 additions & 0 deletions test/lib/e2e-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,3 +187,57 @@ export async function createNext(
flushAllTraces()
}
}

export function createNextDescribe(
name: string,
options: Parameters<typeof createNext>[0] & {
skipDeployment?: boolean
dir?: string
},
fn: (context: {
isNextDev: boolean
isNextDeploy: boolean
isNextStart: boolean
next: NextInstance
}) => void
): void {
describe(name, () => {
if (options.skipDeployment) {
// When the environment is running for deployment tests.
if ((global as any).isNextDeploy) {
it('should skip next deploy', () => {})
// No tests are run.
return
}
}

let next: NextInstance
beforeAll(async () => {
next = await createNext(options)
})
afterAll(async () => {
await next.destroy()
})

const nextProxy = new Proxy<NextInstance>({} as NextInstance, {
get: function (_target, property) {
return next[property]
},
})
fn({
get isNextDev(): boolean {
return Boolean((global as any).isNextDev)
},

get isNextDeploy(): boolean {
return Boolean((global as any).isNextDeploy)
},
get isNextStart(): boolean {
return Boolean((global as any).isNextStart)
},
get next() {
return nextProxy
},
})
})
}
67 changes: 60 additions & 7 deletions test/lib/next-modes/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import { FileRef } from '../e2e-utils'
import { ChildProcess } from 'child_process'
import { createNextInstall } from '../create-next-install'
import { Span } from 'next/trace'
import webdriver from 'next-webdriver'
import { renderViaHTTP, fetchViaHTTP } from 'next-test-utils'
import cheerio from 'cheerio'

type Event = 'stdout' | 'stderr' | 'error' | 'destroy'
export type InstallCommand =
Expand All @@ -18,7 +21,7 @@ export type PackageJson = {
[key: string]: unknown
}
export interface NextInstanceOpts {
files: FileRef | { [filename: string]: string | FileRef }
files: FileRef | string | { [filename: string]: string | FileRef }
dependencies?: { [name: string]: string }
packageJson?: PackageJson
packageLockPath?: string
Expand All @@ -31,6 +34,16 @@ export interface NextInstanceOpts {
turbo?: boolean
}

/**
* Omit the first argument of a function
*/
type OmitFirstArgument<F> = F extends (
firstArgument: any,
...args: infer P
) => infer R
? (...args: P) => R
: never

export class NextInstance {
protected files: FileRef | { [filename: string]: string | FileRef }
protected nextConfig?: NextConfig
Expand All @@ -57,20 +70,23 @@ export class NextInstance {
}

protected async writeInitialFiles() {
if (this.files instanceof FileRef) {
// Handle case where files is a directory string
const files =
typeof this.files === 'string' ? new FileRef(this.files) : this.files
if (files instanceof FileRef) {
// if a FileRef is passed directly to `files` we copy the
// entire folder to the test directory
const stats = await fs.stat(this.files.fsPath)
const stats = await fs.stat(files.fsPath)

if (!stats.isDirectory()) {
throw new Error(
`FileRef passed to "files" in "createNext" is not a directory ${this.files.fsPath}`
`FileRef passed to "files" in "createNext" is not a directory ${files.fsPath}`
)
}
await fs.copy(this.files.fsPath, this.testDir)
await fs.copy(files.fsPath, this.testDir)
} else {
for (const filename of Object.keys(this.files)) {
const item = this.files[filename]
for (const filename of Object.keys(files)) {
const item = files[filename]
const outputFilename = path.join(this.testDir, filename)

if (typeof item === 'string') {
Expand Down Expand Up @@ -363,6 +379,43 @@ export class NextInstance {
return fs.remove(path.join(this.testDir, filename))
}

/**
* Create new browser window for the Next.js app.
*/
public async browser(
...args: Parameters<OmitFirstArgument<typeof webdriver>>
) {
return webdriver(this.url, ...args)
}

/**
* Fetch the HTML for the provided page. This is a shortcut for `renderViaHTTP().then(html => cheerio.load(html))`.
*/
public async render$(
...args: Parameters<OmitFirstArgument<typeof renderViaHTTP>>
): Promise<ReturnType<typeof cheerio.load>> {
const html = await renderViaHTTP(this.url, ...args)
return cheerio.load(html)
}

/**
* Fetch the HTML for the provided page. This is a shortcut for `fetchViaHTTP().then(res => res.text())`.
*/
public async render(
...args: Parameters<OmitFirstArgument<typeof renderViaHTTP>>
) {
return renderViaHTTP(this.url, ...args)
}

/**
* Fetch the HTML for the provided page.
*/
public async fetch(
...args: Parameters<OmitFirstArgument<typeof fetchViaHTTP>>
) {
return fetchViaHTTP(this.url, ...args)
}

public on(event: Event, cb: (...args: any[]) => any) {
if (!this.events[event]) {
this.events[event] = new Set()
Expand Down

0 comments on commit f6ccccd

Please sign in to comment.