diff --git a/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/last-modified.jsx b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/last-modified.jsx index 35df71aa8770..f7f04899d646 100644 --- a/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/last-modified.jsx +++ b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/last-modified.jsx @@ -1,9 +1,12 @@ +import { getSentinelValue } from './sentinel' + export async function LastModified({ params }) { const { slug } = await params return ( -

- Page /{slug} last modified: {new Date().toISOString()} +

+ Page /{slug} last modified: {new Date().toISOString()} ( + {getSentinelValue()})

) } diff --git a/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/with-suspense/layout.jsx b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/with-suspense/layout.jsx index 7e85641b121d..b742a76f981e 100644 --- a/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/with-suspense/layout.jsx +++ b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/with-suspense/layout.jsx @@ -7,8 +7,8 @@ export default async function Layout({ children }) { return ( -
- Layout: {new Date().toISOString()} +
+ Layout: {new Date().toISOString()} ({getSentinelValue()})
Loading...

}>{children}
diff --git a/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/layout.jsx b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/layout.jsx index a9acdbda7e2b..ad3ada30c1da 100644 --- a/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/layout.jsx +++ b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/layout.jsx @@ -6,8 +6,8 @@ export default async function Layout({ children }) { return ( -
- Layout: {new Date().toISOString()} +
+ Layout: {new Date().toISOString()} ({getSentinelValue()})
{children} diff --git a/test/e2e/app-dir/empty-fallback-shells/app/without-io/[slug]/page.jsx b/test/e2e/app-dir/empty-fallback-shells/app/without-io/[slug]/page.jsx index aa74c6b2a192..1d7464925457 100644 --- a/test/e2e/app-dir/empty-fallback-shells/app/without-io/[slug]/page.jsx +++ b/test/e2e/app-dir/empty-fallback-shells/app/without-io/[slug]/page.jsx @@ -1,6 +1,6 @@ export default async function Page({ params }) { const { slug } = await params - return
Hello /{slug}
+ return
Hello /{slug}
} export async function generateStaticParams() { diff --git a/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts b/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts index cfc4ce7ece8b..b2082f93ba4a 100644 --- a/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts +++ b/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts @@ -7,17 +7,19 @@ describe('empty-fallback-shells', () => { describe('without IO', () => { it('should start and not postpone the response', async () => { - const res = await next.fetch('/without-io/world') - const html = await res.text() - expect(html).toContain('hello-world') + const { browser, response } = + await next.browserWithResponse('/without-io/world') + + expect(await browser.elementById('slug').text()).toBe('Hello /world') + const headers = response.headers() if (isNextDeploy) { - expect(res.headers.get('x-matched-path')).toBe('/without-io/[slug]') + expect(headers['x-matched-path']).toBe('/without-io/[slug]') } // If we didn't use the fallback shell, then we didn't postpone the // response and therefore shouldn't have sent the postponed header. - expect(res.headers.get('x-nextjs-postponed')).not.toBe('1') + expect(headers['x-nextjs-postponed']).not.toBe('1') }) }) @@ -25,75 +27,75 @@ describe('empty-fallback-shells', () => { describe('and the page wrapped in Suspense', () => { describe('and the params accessed in the cached page', () => { it('resumes a postponed fallback shell', async () => { - const res = await next.fetch( + const { browser, response } = await next.browserWithResponse( '/with-cached-io/with-suspense/params-in-page/bar' ) - const html = await res.text() - expect(html).toContain('page-bar') + const lastModified = await browser.elementById('last-modified').text() + expect(lastModified).toInclude('Page /bar') + expect(lastModified).toInclude('runtime') - if (isNextDev) { - expect(html).toContain('layout-runtime') - } else { - expect(html).toContain('layout-buildtime') - } + const layout = await browser.elementById('layout').text() + expect(layout).toInclude(isNextDev ? 'runtime' : 'buildtime') + + const headers = response.headers() if (isNextDeploy) { - expect(res.headers.get('x-matched-path')).toBe( + expect(headers['x-matched-path']).toBe( '/with-cached-io/with-suspense/params-in-page/[slug]' ) } else if (isNextStart) { - expect(res.headers.get('x-nextjs-postponed')).toBe('1') + expect(headers['x-nextjs-postponed']).toBe('1') } }) }) describe('and the params accessed in cached non-page function', () => { it('resumes a postponed fallback shell', async () => { - const res = await next.fetch( + const { browser, response } = await next.browserWithResponse( '/with-cached-io/with-suspense/params-not-in-page/bar' ) - const html = await res.text() - expect(html).toContain('page-bar') + const lastModified = await browser.elementById('last-modified').text() + expect(lastModified).toInclude('Page /bar') + expect(lastModified).toInclude('runtime') - if (isNextDev) { - expect(html).toContain('layout-runtime') - } else { - expect(html).toContain('layout-buildtime') - } + const layout = await browser.elementById('layout').text() + expect(layout).toInclude(isNextDev ? 'runtime' : 'buildtime') + + const headers = response.headers() if (isNextDeploy) { - expect(res.headers.get('x-matched-path')).toBe( + expect(headers['x-matched-path']).toBe( '/with-cached-io/with-suspense/params-not-in-page/[slug]' ) } else if (isNextStart) { - expect(res.headers.get('x-nextjs-postponed')).toBe('1') + expect(headers['x-nextjs-postponed']).toBe('1') } }) }) describe('and params.then/catch/finally passed to a cached function', () => { it('resumes a postponed fallback shell', async () => { - const res = await next.fetch( + const { browser, response } = await next.browserWithResponse( '/with-cached-io/with-suspense/params-then-in-page/bar' ) - const html = await res.text() - expect(html).toIncludeRepeated('data-testid="page-bar"', 4) + const lastModified = await browser.elementById('last-modified').text() + expect(lastModified).toInclude('Page /bar') + expect(lastModified).toInclude('runtime') - if (isNextDev) { - expect(html).toContain('layout-runtime') - } else { - expect(html).toContain('layout-buildtime') - } + const layout = await browser.elementById('layout').text() + expect(layout).toInclude(isNextDev ? 'runtime' : 'buildtime') + + const headers = response.headers() if (isNextDeploy) { - expect(res.headers.get('x-matched-path')).toBe( + expect(headers['x-matched-path']).toBe( '/with-cached-io/with-suspense/params-then-in-page/[slug]' ) } else if (isNextStart) { - expect(res.headers.get('x-nextjs-postponed')).toBe('1') + expect(headers['x-nextjs-postponed']).toBe('1') } }) }) @@ -102,77 +104,92 @@ describe('empty-fallback-shells', () => { describe('and the page not wrapped in Suspense', () => { describe('and the params accessed in the cached page', () => { it('does not resume a postponed fallback shell', async () => { - const res = await next.fetch( + const { browser, response } = await next.browserWithResponse( '/with-cached-io/without-suspense/params-in-page/bar' ) - const html = await res.text() - expect(html).toContain('page-bar') - expect(html).toContain('layout-runtime') + const lastModified = await browser.elementById('last-modified').text() + expect(lastModified).toInclude('Page /bar') + expect(lastModified).toInclude('runtime') + + const layout = await browser.elementById('layout').text() + expect(layout).toInclude('runtime') + + const headers = response.headers() if (isNextDeploy) { - expect(res.headers.get('x-matched-path')).toBe( + expect(headers['x-matched-path']).toBe( '/with-cached-io/without-suspense/params-in-page/[slug]' ) - } else { - expect(res.headers.get('x-nextjs-postponed')).not.toBe('1') + } else if (isNextStart) { + expect(headers['x-nextjs-postponed']).not.toBe('1') } }) it('does not render a fallback shell when using a params placeholder', async () => { // This should trigger a blocking prerender of the route shell. - const res = await next.fetch( + const { browser, response } = await next.browserWithResponse( '/with-cached-io/without-suspense/params-in-page/[slug]' ) - expect(res.status).toBe(200) - - const html = await res.text() + expect(response.status()).toBe(200) // This should render the encoded param in the route shell, and not // interpret the param as a fallback param, and subsequently try to // render the fallback shell instead, which would fail because of the // missing parent suspense boundary. - expect(html).toContain('page-%5Bslug%5D') + const lastModified = await browser.elementById('last-modified').text() + expect(lastModified).toInclude('Page /%5Bslug%5D') + expect(lastModified).toInclude('runtime') }) }) describe('and the params accessed in a cached non-page function', () => { it('does not resume a postponed fallback shell', async () => { - const res = await next.fetch( + const { browser, response } = await next.browserWithResponse( '/with-cached-io/without-suspense/params-not-in-page/bar' ) - const html = await res.text() - expect(html).toContain('page-bar') - expect(html).toContain('layout-runtime') + const lastModified = await browser.elementById('last-modified').text() + expect(lastModified).toInclude('Page /bar') + expect(lastModified).toInclude('runtime') + + const layout = await browser.elementById('layout').text() + expect(layout).toInclude('runtime') + + const headers = response.headers() if (isNextDeploy) { - expect(res.headers.get('x-matched-path')).toBe( + expect(headers['x-matched-path']).toBe( '/with-cached-io/without-suspense/params-not-in-page/[slug]' ) - } else { - expect(res.headers.get('x-nextjs-postponed')).not.toBe('1') + } else if (isNextStart) { + expect(headers['x-nextjs-postponed']).not.toBe('1') } }) }) describe('and params.then/catch/finally passed to a cached function', () => { it('does not resume a postponed fallback shell', async () => { - const res = await next.fetch( + const { browser, response } = await next.browserWithResponse( '/with-cached-io/without-suspense/params-then-in-page/bar' ) - const html = await res.text() - expect(html).toIncludeRepeated('data-testid="page-bar"', 4) - expect(html).toContain('layout-runtime') + const lastModified = await browser.elementById('last-modified').text() + expect(lastModified).toInclude('Page /bar') + expect(lastModified).toInclude('runtime') + + const layout = await browser.elementById('layout').text() + expect(layout).toInclude('runtime') + + const headers = response.headers() if (isNextDeploy) { - expect(res.headers.get('x-matched-path')).toBe( + expect(headers['x-matched-path']).toBe( '/with-cached-io/without-suspense/params-then-in-page/[slug]' ) - } else { - expect(res.headers.get('x-nextjs-postponed')).not.toBe('1') + } else if (isNextStart) { + expect(headers['x-nextjs-postponed']).not.toBe('1') } }) }) diff --git a/test/lib/next-modes/base.ts b/test/lib/next-modes/base.ts index c849aafd5f4a..19aaafc47039 100644 --- a/test/lib/next-modes/base.ts +++ b/test/lib/next-modes/base.ts @@ -13,6 +13,7 @@ import cheerio from 'cheerio' import { once } from 'events' import { Playwright } from 'next-webdriver' import escapeStringRegexp from 'escape-string-regexp' +import { Page, Response } from 'playwright' type Event = 'stdout' | 'stderr' | 'error' | 'destroy' export type InstallCommand = @@ -664,7 +665,7 @@ export class NextInstance { } /** - * Create new browser window for the Next.js app. + * Create a new browser window for the Next.js app. */ public async browser( ...args: Parameters> @@ -672,6 +673,49 @@ export class NextInstance { return webdriver(this.url, ...args) } + /** + * Create a new browser window for the Next.js app, and also return the page's + * response. + */ + public async browserWithResponse( + ...args: Parameters> + ): Promise<{ browser: Playwright; response: Response }> { + const [url, options = {}] = args + + let resolveResponse: (response: Response) => void + + const responsePromise = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(`Timed out waiting for the response of ${url}`) + }, 10_000) + + resolveResponse = (response: Response) => { + clearTimeout(timer) + resolve(response) + } + }) + + const absoluteUrl = new URL(url, this.url).href + + const [browser, response] = await Promise.all([ + webdriver(this.url, url, { + ...options, + beforePageLoad(page: Page) { + options.beforePageLoad?.(page) + + page.on('response', async (response) => { + if (response.url() === absoluteUrl) { + resolveResponse(response) + } + }) + }, + }), + responsePromise, + ]) + + return { browser, response } + } + /** * Fetch the HTML for the provided page. This is a shortcut for `renderViaHTTP().then(html => cheerio.load(html))`. */