Image component foundation (#17343)
Co-authored-by: Tim Neutkens <timneutkens@me.com> Co-authored-by: Tim Neutkens <tim@timneutkens.nl>
This commit is contained in:
parent
9a5a1525bc
commit
87175fe9df
14 changed files with 564 additions and 1 deletions
|
@ -229,6 +229,25 @@ export default async function getBaseWebpackConfig(
|
|||
}
|
||||
}
|
||||
|
||||
if (config.images?.hosts) {
|
||||
if (!config.images.hosts.default) {
|
||||
// If the image component is being used, a default host must be provided
|
||||
throw new Error(
|
||||
'If the image configuration property is present in next.config.js, it must have a host named "default"'
|
||||
)
|
||||
}
|
||||
Object.values(config.images.hosts).forEach((host: any) => {
|
||||
if (!host.path) {
|
||||
throw new Error(
|
||||
'All hosts defined in the image configuration property of next.config.js must define a path'
|
||||
)
|
||||
}
|
||||
// Normalize hosts so all paths have trailing slash
|
||||
if (host.path[host.path.length - 1] !== '/') {
|
||||
host.path += '/'
|
||||
}
|
||||
})
|
||||
}
|
||||
const reactVersion = await getPackageVersion({ cwd: dir, name: 'react' })
|
||||
const hasReactRefresh: boolean = dev && !isServer
|
||||
const hasJsxRuntime: boolean =
|
||||
|
@ -583,6 +602,7 @@ export default async function getBaseWebpackConfig(
|
|||
'next/app',
|
||||
'next/document',
|
||||
'next/link',
|
||||
'next/image',
|
||||
'next/error',
|
||||
'string-hash',
|
||||
'next/constants',
|
||||
|
@ -984,6 +1004,7 @@ export default async function getBaseWebpackConfig(
|
|||
'process.env.__NEXT_SCROLL_RESTORATION': JSON.stringify(
|
||||
config.experimental.scrollRestoration
|
||||
),
|
||||
'process.env.__NEXT_IMAGE_OPTS': JSON.stringify(config.images),
|
||||
'process.env.__NEXT_ROUTER_BASEPATH': JSON.stringify(config.basePath),
|
||||
'process.env.__NEXT_HAS_REWRITES': JSON.stringify(hasRewrites),
|
||||
'process.env.__NEXT_i18n_SUPPORT': JSON.stringify(
|
||||
|
|
|
@ -684,7 +684,6 @@ const nextServerlessLoader: loader.Loader = function () {
|
|||
|
||||
const previewData = tryGetPreviewData(req, res, options.previewProps)
|
||||
const isPreviewMode = previewData !== false
|
||||
|
||||
if (process.env.__NEXT_OPTIMIZE_FONTS) {
|
||||
renderOpts.optimizeFonts = true
|
||||
/**
|
||||
|
|
183
packages/next/client/image.tsx
Normal file
183
packages/next/client/image.tsx
Normal file
|
@ -0,0 +1,183 @@
|
|||
import React, { ReactElement } from 'react'
|
||||
import Head from '../next-server/lib/head'
|
||||
|
||||
const loaders: { [key: string]: (props: LoaderProps) => string } = {
|
||||
imgix: imgixLoader,
|
||||
cloudinary: cloudinaryLoader,
|
||||
default: defaultLoader,
|
||||
}
|
||||
type ImageData = {
|
||||
hosts: {
|
||||
[key: string]: {
|
||||
path: string
|
||||
loader: string
|
||||
}
|
||||
}
|
||||
breakpoints?: number[]
|
||||
}
|
||||
|
||||
type ImageProps = {
|
||||
src: string
|
||||
host: string
|
||||
sizes: string
|
||||
breakpoints: number[]
|
||||
priority: boolean
|
||||
unoptimized: boolean
|
||||
rest: any[]
|
||||
}
|
||||
|
||||
let imageData: any = process.env.__NEXT_IMAGE_OPTS
|
||||
const breakpoints = imageData.breakpoints || [640, 1024, 1600]
|
||||
|
||||
function computeSrc(src: string, host: string, unoptimized: boolean): string {
|
||||
if (unoptimized) {
|
||||
return src
|
||||
}
|
||||
if (!host) {
|
||||
// No host provided, use default
|
||||
return callLoader(src, 'default')
|
||||
} else {
|
||||
let selectedHost = imageData.hosts[host]
|
||||
if (!selectedHost) {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.error(
|
||||
`Image tag is used specifying host ${host}, but that host is not defined in next.config`
|
||||
)
|
||||
}
|
||||
return src
|
||||
}
|
||||
return callLoader(src, host)
|
||||
}
|
||||
}
|
||||
|
||||
function callLoader(src: string, host: string, width?: number): string {
|
||||
let loader = loaders[imageData.hosts[host].loader || 'default']
|
||||
return loader({ root: imageData.hosts[host].path, filename: src, width })
|
||||
}
|
||||
|
||||
type SrcSetData = {
|
||||
src: string
|
||||
host: string
|
||||
widths: number[]
|
||||
}
|
||||
|
||||
function generateSrcSet({ src, host, widths }: SrcSetData): string {
|
||||
// At each breakpoint, generate an image url using the loader, such as:
|
||||
// ' www.example.com/foo.jpg?w=480 480w, '
|
||||
return widths
|
||||
.map((width: number) => `${callLoader(src, host, width)} ${width}w`)
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
type PreloadData = {
|
||||
src: string
|
||||
host: string
|
||||
widths: number[]
|
||||
sizes: string
|
||||
unoptimized: boolean
|
||||
}
|
||||
|
||||
function generatePreload({
|
||||
src,
|
||||
host,
|
||||
widths,
|
||||
unoptimized,
|
||||
sizes,
|
||||
}: PreloadData): ReactElement {
|
||||
// This function generates an image preload that makes use of the "imagesrcset" and "imagesizes"
|
||||
// attributes for preloading responsive images. They're still experimental, but fully backward
|
||||
// compatible, as the link tag includes all necessary attributes, even if the final two are ignored.
|
||||
// See: https://web.dev/preload-responsive-images/
|
||||
return (
|
||||
<Head>
|
||||
<link
|
||||
rel="preload"
|
||||
as="image"
|
||||
href={computeSrc(src, host, unoptimized)}
|
||||
// @ts-ignore: imagesrcset and imagesizes not yet in the link element type
|
||||
imagesrcset={generateSrcSet({ src, host, widths })}
|
||||
imagesizes={sizes}
|
||||
/>
|
||||
</Head>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Image({
|
||||
src,
|
||||
host,
|
||||
sizes,
|
||||
unoptimized,
|
||||
priority,
|
||||
...rest
|
||||
}: ImageProps) {
|
||||
// Sanity Checks:
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
if (unoptimized && host) {
|
||||
console.error(`Image tag used specifying both a host and the unoptimized attribute--these are mutually exclusive.
|
||||
With the unoptimized attribute, no host will be used, so specify an absolute URL.`)
|
||||
}
|
||||
}
|
||||
if (host && !imageData.hosts[host]) {
|
||||
// If unregistered host is selected, log an error and use the default instead
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.error(`Image host identifier ${host} could not be resolved.`)
|
||||
}
|
||||
host = 'default'
|
||||
}
|
||||
|
||||
host = host || 'default'
|
||||
|
||||
// Normalize provided src
|
||||
if (src[0] === '/') {
|
||||
src = src.slice(1)
|
||||
}
|
||||
|
||||
// Generate attribute values
|
||||
const imgSrc = computeSrc(src, host, unoptimized)
|
||||
const imgAttributes: { src: string; srcSet?: string } = { src: imgSrc }
|
||||
if (!unoptimized) {
|
||||
imgAttributes.srcSet = generateSrcSet({
|
||||
src,
|
||||
host: host,
|
||||
widths: breakpoints,
|
||||
})
|
||||
}
|
||||
// No need to add preloads on the client side--by the time the application is hydrated,
|
||||
// it's too late for preloads
|
||||
const shouldPreload = priority && typeof window === 'undefined'
|
||||
|
||||
return (
|
||||
<div>
|
||||
{shouldPreload
|
||||
? generatePreload({
|
||||
src,
|
||||
host,
|
||||
widths: breakpoints,
|
||||
unoptimized,
|
||||
sizes,
|
||||
})
|
||||
: ''}
|
||||
<img {...rest} {...imgAttributes} sizes={sizes} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
//BUILT IN LOADERS
|
||||
|
||||
type LoaderProps = {
|
||||
root: string
|
||||
filename: string
|
||||
width?: number
|
||||
}
|
||||
|
||||
function imgixLoader({ root, filename, width }: LoaderProps): string {
|
||||
return `${root}${filename}${width ? '?w=' + width : ''}`
|
||||
}
|
||||
|
||||
function cloudinaryLoader({ root, filename, width }: LoaderProps): string {
|
||||
return `${root}${width ? 'w_' + width + '/' : ''}${filename}`
|
||||
}
|
||||
|
||||
function defaultLoader({ root, filename }: LoaderProps): string {
|
||||
return `${root}${filename}`
|
||||
}
|
2
packages/next/image.d.ts
vendored
Normal file
2
packages/next/image.d.ts
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
export * from './dist/client/image'
|
||||
export { default } from './dist/client/image'
|
1
packages/next/image.js
Normal file
1
packages/next/image.js
Normal file
|
@ -0,0 +1 @@
|
|||
module.exports = require('./dist/client/image')
|
|
@ -23,6 +23,7 @@ const defaultConfig: { [key: string]: any } = {
|
|||
target: 'server',
|
||||
poweredByHeader: true,
|
||||
compress: true,
|
||||
images: { hosts: { default: { path: 'defaultconfig' } } },
|
||||
devIndicators: {
|
||||
buildActivity: true,
|
||||
autoPrerender: true,
|
||||
|
|
|
@ -137,6 +137,7 @@ export default class Server {
|
|||
ampOptimizerConfig?: { [key: string]: any }
|
||||
basePath: string
|
||||
optimizeFonts: boolean
|
||||
images: string
|
||||
fontManifest: FontManifest
|
||||
optimizeImages: boolean
|
||||
locale?: string
|
||||
|
@ -188,6 +189,7 @@ export default class Server {
|
|||
customServer: customServer === true ? true : undefined,
|
||||
ampOptimizerConfig: this.nextConfig.experimental.amp?.optimizer,
|
||||
basePath: this.nextConfig.basePath,
|
||||
images: JSON.stringify(this.nextConfig.images),
|
||||
optimizeFonts: this.nextConfig.experimental.optimizeFonts && !dev,
|
||||
fontManifest:
|
||||
this.nextConfig.experimental.optimizeFonts && !dev
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react'
|
||||
|
||||
const page = () => {
|
||||
return <div>Hello</div>
|
||||
}
|
||||
|
||||
export default page
|
|
@ -0,0 +1,76 @@
|
|||
/* eslint-env jest */
|
||||
|
||||
import { join } from 'path'
|
||||
import { nextBuild } from 'next-test-utils'
|
||||
import fs from 'fs-extra'
|
||||
|
||||
jest.setTimeout(1000 * 30)
|
||||
|
||||
const appDir = join(__dirname, '../')
|
||||
const nextConfig = join(appDir, 'next.config.js')
|
||||
|
||||
describe('Next.config.js images prop without default host', () => {
|
||||
let build
|
||||
beforeAll(async () => {
|
||||
await fs.writeFile(
|
||||
nextConfig,
|
||||
`module.exports = {
|
||||
images: {
|
||||
hosts: {
|
||||
secondary: {
|
||||
path: 'https://examplesecondary.com/images/',
|
||||
loader: 'cloudinary',
|
||||
},
|
||||
},
|
||||
breakpoints: [480, 1024, 1600],
|
||||
},
|
||||
}`,
|
||||
'utf8'
|
||||
)
|
||||
build = await nextBuild(appDir, [], {
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
})
|
||||
})
|
||||
it('Should error during build if images prop in next.config is malformed', () => {
|
||||
expect(build.stderr).toContain(
|
||||
'If the image configuration property is present in next.config.js, it must have a host named "default"'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Next.config.js images prop without path', () => {
|
||||
let build
|
||||
beforeAll(async () => {
|
||||
await fs.writeFile(
|
||||
nextConfig,
|
||||
`module.exports = {
|
||||
images: {
|
||||
hosts: {
|
||||
default: {
|
||||
path: 'https://examplesecondary.com/images/',
|
||||
loader: 'cloudinary',
|
||||
},
|
||||
secondary: {
|
||||
loader: 'cloudinary',
|
||||
},
|
||||
},
|
||||
breakpoints: [480, 1024, 1600],
|
||||
},
|
||||
}`,
|
||||
'utf8'
|
||||
)
|
||||
build = await nextBuild(appDir, [], {
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
})
|
||||
})
|
||||
afterAll(async () => {
|
||||
await fs.remove(nextConfig)
|
||||
})
|
||||
it('Should error during build if images prop in next.config is malformed', () => {
|
||||
expect(build.stderr).toContain(
|
||||
'All hosts defined in the image configuration property of next.config.js must define a path'
|
||||
)
|
||||
})
|
||||
})
|
15
test/integration/image-component/basic/next.config.js
Normal file
15
test/integration/image-component/basic/next.config.js
Normal file
|
@ -0,0 +1,15 @@
|
|||
module.exports = {
|
||||
images: {
|
||||
hosts: {
|
||||
default: {
|
||||
path: 'https://example.com/myaccount/',
|
||||
loader: 'imgix',
|
||||
},
|
||||
secondary: {
|
||||
path: 'https://examplesecondary.com/images/',
|
||||
loader: 'cloudinary',
|
||||
},
|
||||
},
|
||||
breakpoints: [480, 1024, 1600],
|
||||
},
|
||||
}
|
31
test/integration/image-component/basic/pages/client-side.js
Normal file
31
test/integration/image-component/basic/pages/client-side.js
Normal file
|
@ -0,0 +1,31 @@
|
|||
import React from 'react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
|
||||
const ClientSide = () => {
|
||||
return (
|
||||
<div>
|
||||
<p id="stubtext">This is a client side page</p>
|
||||
<Image id="basic-image" src="foo.jpg"></Image>
|
||||
<Image id="attribute-test" data-demo="demo-value" src="bar.jpg" />
|
||||
<Image
|
||||
id="secondary-image"
|
||||
data-demo="demo-value"
|
||||
host="secondary"
|
||||
src="foo2.jpg"
|
||||
/>
|
||||
<Image
|
||||
id="unoptimized-image"
|
||||
unoptimized
|
||||
src="https://arbitraryurl.com/foo.jpg"
|
||||
/>
|
||||
<Image id="priority-image-client" priority src="withpriorityclient.png" />
|
||||
<Image id="preceding-slash-image" src="/fooslash.jpg" priority />
|
||||
<Link href="/errors">
|
||||
<a id="errorslink">Errors</a>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ClientSide
|
13
test/integration/image-component/basic/pages/errors.js
Normal file
13
test/integration/image-component/basic/pages/errors.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
import React from 'react'
|
||||
import Image from 'next/image'
|
||||
|
||||
const Errors = () => {
|
||||
return (
|
||||
<div>
|
||||
<p id="stubtext">This is a page with errors</p>
|
||||
<Image id="nonexistant-host" host="nope" src="wronghost.jpg"></Image>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Errors
|
44
test/integration/image-component/basic/pages/index.js
Normal file
44
test/integration/image-component/basic/pages/index.js
Normal file
|
@ -0,0 +1,44 @@
|
|||
import React from 'react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<div>
|
||||
<p>Hello World</p>
|
||||
<Image id="basic-image" src="foo.jpg"></Image>
|
||||
<Image id="attribute-test" data-demo="demo-value" src="bar.jpg" />
|
||||
<Image
|
||||
id="secondary-image"
|
||||
data-demo="demo-value"
|
||||
host="secondary"
|
||||
src="foo2.jpg"
|
||||
/>
|
||||
<Image
|
||||
id="unoptimized-image"
|
||||
unoptimized
|
||||
src="https://arbitraryurl.com/foo.jpg"
|
||||
/>
|
||||
<Image id="priority-image" priority src="withpriority.png" />
|
||||
<Image
|
||||
id="priority-image"
|
||||
priority
|
||||
host="secondary"
|
||||
src="withpriority2.png"
|
||||
/>
|
||||
<Image
|
||||
id="priority-image"
|
||||
priority
|
||||
unoptimized
|
||||
src="https://arbitraryurl.com/withpriority3.png"
|
||||
/>
|
||||
<Image id="preceding-slash-image" src="/fooslash.jpg" priority />
|
||||
<Link href="/client-side">
|
||||
<a id="clientlink">Client Side</a>
|
||||
</Link>
|
||||
<p id="stubtext">This is the index page</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Page
|
168
test/integration/image-component/basic/test/index.test.js
Normal file
168
test/integration/image-component/basic/test/index.test.js
Normal file
|
@ -0,0 +1,168 @@
|
|||
/* eslint-env jest */
|
||||
|
||||
import { join } from 'path'
|
||||
import {
|
||||
killApp,
|
||||
findPort,
|
||||
nextStart,
|
||||
nextBuild,
|
||||
waitFor,
|
||||
} from 'next-test-utils'
|
||||
import webdriver from 'next-webdriver'
|
||||
|
||||
jest.setTimeout(1000 * 30)
|
||||
|
||||
const appDir = join(__dirname, '../')
|
||||
let appPort
|
||||
let app
|
||||
let browser
|
||||
|
||||
function runTests() {
|
||||
it('should render an image tag', async () => {
|
||||
await waitFor(1000)
|
||||
expect(await browser.hasElementByCssSelector('img')).toBeTruthy()
|
||||
})
|
||||
it('should support passing through arbitrary attributes', async () => {
|
||||
expect(
|
||||
await browser.hasElementByCssSelector('img#attribute-test')
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
await browser.elementByCss('img#attribute-test').getAttribute('data-demo')
|
||||
).toBe('demo-value')
|
||||
})
|
||||
it('should modify src with the loader', async () => {
|
||||
expect(await browser.elementById('basic-image').getAttribute('src')).toBe(
|
||||
'https://example.com/myaccount/foo.jpg'
|
||||
)
|
||||
})
|
||||
it('should correctly generate src even if preceding slash is included in prop', async () => {
|
||||
expect(
|
||||
await browser.elementById('preceding-slash-image').getAttribute('src')
|
||||
).toBe('https://example.com/myaccount/fooslash.jpg')
|
||||
})
|
||||
it('should support manually selecting a different host', async () => {
|
||||
expect(
|
||||
await browser.elementById('secondary-image').getAttribute('src')
|
||||
).toBe('https://examplesecondary.com/images/foo2.jpg')
|
||||
})
|
||||
it('should add a srcset based on the loader', async () => {
|
||||
expect(
|
||||
await browser.elementById('basic-image').getAttribute('srcset')
|
||||
).toBe(
|
||||
'https://example.com/myaccount/foo.jpg?w=480 480w, https://example.com/myaccount/foo.jpg?w=1024 1024w, https://example.com/myaccount/foo.jpg?w=1600 1600w'
|
||||
)
|
||||
})
|
||||
it('should add a srcset even with preceding slash in prop', async () => {
|
||||
expect(
|
||||
await browser.elementById('preceding-slash-image').getAttribute('srcset')
|
||||
).toBe(
|
||||
'https://example.com/myaccount/fooslash.jpg?w=480 480w, https://example.com/myaccount/fooslash.jpg?w=1024 1024w, https://example.com/myaccount/fooslash.jpg?w=1600 1600w'
|
||||
)
|
||||
})
|
||||
it('should support the unoptimized attribute', async () => {
|
||||
expect(
|
||||
await browser.elementById('unoptimized-image').getAttribute('src')
|
||||
).toBe('https://arbitraryurl.com/foo.jpg')
|
||||
})
|
||||
it('should not add a srcset if unoptimized attribute present', async () => {
|
||||
expect(
|
||||
await browser.elementById('unoptimized-image').getAttribute('srcset')
|
||||
).toBeFalsy()
|
||||
})
|
||||
}
|
||||
|
||||
async function hasPreloadLinkMatchingUrl(url) {
|
||||
const links = await browser.elementsByCss('link')
|
||||
let foundMatch = false
|
||||
for (const link of links) {
|
||||
const rel = await link.getAttribute('rel')
|
||||
const href = await link.getAttribute('href')
|
||||
if (rel === 'preload' && href === url) {
|
||||
foundMatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return foundMatch
|
||||
}
|
||||
|
||||
describe('Image Component Tests', () => {
|
||||
beforeAll(async () => {
|
||||
await nextBuild(appDir)
|
||||
appPort = await findPort()
|
||||
app = await nextStart(appDir, appPort)
|
||||
})
|
||||
afterAll(() => killApp(app))
|
||||
describe('SSR Image Component Tests', () => {
|
||||
beforeAll(async () => {
|
||||
browser = await webdriver(appPort, '/')
|
||||
})
|
||||
afterAll(async () => {
|
||||
browser = null
|
||||
})
|
||||
runTests()
|
||||
it('should add a preload tag for a priority image', async () => {
|
||||
expect(
|
||||
await hasPreloadLinkMatchingUrl(
|
||||
'https://example.com/myaccount/withpriority.png'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
it('should add a preload tag for a priority image with preceding slash', async () => {
|
||||
expect(
|
||||
await hasPreloadLinkMatchingUrl(
|
||||
'https://example.com/myaccount/fooslash.jpg'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
it('should add a preload tag for a priority image, with secondary host', async () => {
|
||||
expect(
|
||||
await hasPreloadLinkMatchingUrl(
|
||||
'https://examplesecondary.com/images/withpriority2.png'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
it('should add a preload tag for a priority image, with arbitrary host', async () => {
|
||||
expect(
|
||||
await hasPreloadLinkMatchingUrl(
|
||||
'https://arbitraryurl.com/withpriority3.png'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
describe('Client-side Image Component Tests', () => {
|
||||
beforeAll(async () => {
|
||||
browser = await webdriver(appPort, '/')
|
||||
await browser.waitForElementByCss('#clientlink').click()
|
||||
})
|
||||
afterAll(async () => {
|
||||
browser = null
|
||||
})
|
||||
runTests()
|
||||
it('should NOT add a preload tag for a priority image', async () => {
|
||||
expect(
|
||||
await hasPreloadLinkMatchingUrl(
|
||||
'https://example.com/myaccount/withpriorityclient.png'
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
describe('Client-side Errors', () => {
|
||||
beforeAll(async () => {
|
||||
await browser.eval(`(function() {
|
||||
window.gotHostError = false
|
||||
const origError = console.error
|
||||
window.console.error = function () {
|
||||
if (arguments[0].match(/Image host identifier/)) {
|
||||
window.gotHostError = true
|
||||
}
|
||||
origError.apply(this, arguments)
|
||||
}
|
||||
})()`)
|
||||
await browser.waitForElementByCss('#errorslink').click()
|
||||
})
|
||||
it('Should not log an error when an unregistered host is used in production', async () => {
|
||||
const foundError = await browser.eval('window.gotHostError')
|
||||
expect(foundError).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
Loading…
Reference in a new issue