mirror of
https://github.com/hanzoai/next.js.git
synced 2026-10-30 14:46:38 +00:00
How long do you expect this to wait at worst (before failing)?
```js
await retry(async () => {
expect(await browser.elementById('never-appears').text()).toBe('x')
}, 15_000)
```
After this PR, the answer is 15 seconds. (Well, technically, 15 seconds
+ whatever last attempt is, so 20.)
But before this PR, the answer was 3 minutes.
## What?
There's a bug (IMO) in `retry`. Instead of waiting for the time you give
it, it counts tries.
For example, `retry(fn, 15_000)` reads as "keep trying for 15 seconds".
However, what it actually was doing is divide 15000 by the 500ms
interval and try 30 times, sleeping 500ms in between every try.
This is a problem because **it wasn't measuring the stall time of each
try itself.**
When a try is cheap, it doesn't matter. But a try that looks for an
element in the browser isn't cheap: if the element isn't there, the
selector waits 5 seconds before giving up.
In the example above, each try sits in the selector for its full 5
seconds, and there are 31 of these intervals, plus the 15 seconds of
sleeping in between. This ends up waiting 170103ms, i.e. three minutes
for a wait that says 15 seconds.
Jest kills the test at its own timeout long before that, and the actual
error gets thrown away. What's left is `Exceeded timeout of 120000 ms`,
which doesn't say which assertion never came true. In the one I measured
it was `page.waitForSelector: Timeout 5000ms exceeded`. My agent lost an
hour trying to make sense of this twice now.
## Fix
We count time now. After the fix, the retry watches the clock and stops
when its time is up. It still always makes at least one try. The
interval no longer has to divide the duration, because nothing counts
steps any more. If the last try is slow, we're gonna wait for that last
try to end (so the timeout is still not *strictly* respected) so that we
can show the error in the correct test.
Cheap waits behave exactly as before. In my local run, a try that had to
repeat cost 251ms at the median, 459ms at the worst. So the only calls
this changes are ones that were already failing, and those now fail with
their own error instead of a test timeout. One flaky dev test that used
to die at 120 seconds with nothing to show now fails in 19.5 seconds
saying `Failed to retry within 15000ms (waited 16022ms)`, next to nine
tests that take 2.2 seconds each.
I added some regression tests. Since it's a test helper, a unit test
seemed fine.
## What this means for existing tests
The catch is that a call whose tries are slow now gets less time than it
used to, and this helper has 2110 call sites, so CI is the real check.
It found one file: the hash scroll tests in `navigation.test.ts` wait
with the default 3 seconds, and each try there costs 150-300ms on a
runner, so they were really getting about 5. They need more than 3, so
they failed on two dev shards. I gave them 10.
If this is green, we can merge it in isolation from the rest of the
stack.
---------
Co-authored-by: Claude Opus 5 <[email protected]>
1105 lines
37 KiB
TypeScript
1105 lines
37 KiB
TypeScript
import { nextTestSetup } from 'e2e-utils'
|
|
import { getTitle, retry, waitFor } from 'next-test-utils'
|
|
|
|
describe('app dir - navigation', () => {
|
|
const { next, isNextDev, isNextStart, isNextDeploy } = nextTestSetup({
|
|
files: __dirname,
|
|
})
|
|
|
|
describe('query string', () => {
|
|
it('should set query correctly', async () => {
|
|
const browser = await next.browser('/')
|
|
expect(await browser.elementById('query').text()).toMatchInlineSnapshot(
|
|
`"<empty-query>"`
|
|
)
|
|
|
|
await browser.elementById('set-query').click()
|
|
|
|
await retry(() =>
|
|
expect(browser.elementById('query').text()).resolves.toEqual('a=b&c=d')
|
|
)
|
|
|
|
const url = new URL(await browser.url())
|
|
expect(url.searchParams.toString()).toMatchInlineSnapshot(`"a=b&c=d"`)
|
|
})
|
|
|
|
it('should set query with semicolon correctly', async () => {
|
|
const browser = await next.browser('/redirect/semicolon')
|
|
|
|
await retry(() =>
|
|
expect(browser.elementById('query').text()).resolves.toEqual('a=b%3Bc')
|
|
)
|
|
|
|
const url = new URL(await browser.url())
|
|
expect(url.searchParams.toString()).toBe('a=b%3Bc')
|
|
})
|
|
|
|
it('should handle unicode search params', async () => {
|
|
const requests: Array<{
|
|
pathname: string
|
|
ok: boolean
|
|
headers: Record<string, string>
|
|
}> = []
|
|
|
|
const browser = await next.browser('/search-params?name=名', {
|
|
beforePageLoad(page) {
|
|
page.on('response', async (res) => {
|
|
requests.push({
|
|
pathname: new URL(res.url()).pathname,
|
|
ok: res.ok(),
|
|
headers: res.headers(),
|
|
})
|
|
})
|
|
},
|
|
})
|
|
|
|
expect(await browser.elementById('name').text()).toBe('名')
|
|
await browser.elementById('link').click()
|
|
await browser.waitForElementByCss('#set-query')
|
|
|
|
await retry(() =>
|
|
expect(requests).toContainEqual({
|
|
pathname: '/',
|
|
ok: true,
|
|
headers: expect.objectContaining({
|
|
'content-type': 'text/x-component',
|
|
}),
|
|
})
|
|
)
|
|
})
|
|
|
|
it('should not reset shallow url updates on prefetch', async () => {
|
|
const browser = await next.browser('/search-params/shallow')
|
|
const button = await browser.elementByCss('button')
|
|
await button.click()
|
|
expect(await browser.url()).toMatch(/\?foo=bar$/)
|
|
const link = await browser.elementByCss('a')
|
|
await link.hover()
|
|
// Hovering a prefetch link should keep the URL intact
|
|
expect(await browser.url()).toMatch(/\?foo=bar$/)
|
|
})
|
|
|
|
describe('useParams identity between renders', () => {
|
|
it.each([
|
|
{
|
|
router: 'app',
|
|
pathname: '/search-params/foo',
|
|
// App Router doesn't re-render on initial load (the params are baked
|
|
// server side).
|
|
waitForNEffects: 1,
|
|
},
|
|
{
|
|
router: 'pages',
|
|
pathname: '/search-params-pages/foo',
|
|
// Pages Router re-renders on initial load and after hydration, the
|
|
// params when initially loaded are null.
|
|
waitForNEffects: 2,
|
|
},
|
|
])(
|
|
'should be stable in $router',
|
|
async ({ pathname, waitForNEffects }) => {
|
|
const browser = await next.browser(pathname)
|
|
|
|
// Expect to see the params changed message at least twice.
|
|
let lastLogIndex = await retry(async () => {
|
|
const logs: Array<{ message: string }> = await browser.log()
|
|
|
|
expect(
|
|
logs.filter(({ message }) => message === 'params changed')
|
|
).toHaveLength(waitForNEffects)
|
|
|
|
return logs.length
|
|
})
|
|
|
|
await browser.elementById('rerender-button').click()
|
|
await browser.elementById('rerender-button').click()
|
|
await browser.elementById('rerender-button').click()
|
|
|
|
await retry(async () => {
|
|
const rerender = await browser.elementById('rerender-button').text()
|
|
|
|
expect(rerender).toBe('Re-Render 3')
|
|
})
|
|
|
|
let logs: Array<{ message: string }> = await browser.log()
|
|
expect(logs.slice(lastLogIndex)).not.toContainEqual(
|
|
expect.objectContaining({
|
|
message: 'params changed',
|
|
})
|
|
)
|
|
|
|
lastLogIndex = logs.length
|
|
|
|
await browser.elementById('change-params-button').click()
|
|
|
|
await retry(async () => {
|
|
logs = await browser.log()
|
|
|
|
expect(logs.slice(lastLogIndex)).toContainEqual(
|
|
expect.objectContaining({
|
|
message: 'params changed',
|
|
})
|
|
)
|
|
})
|
|
}
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('hash', () => {
|
|
it('should scroll to the specified hash', async () => {
|
|
// Requests made to navigate, excluding prefetches: a hash-only
|
|
// navigation is same-document and must not fetch data, while
|
|
// prefetch heuristics are free to fire at any time.
|
|
const navigationRscRequestUrls = new Set<string>()
|
|
const browser = await next.browser('/hash', {
|
|
beforePageLoad(page) {
|
|
page.on('request', (req) => {
|
|
const headers = req.headers()
|
|
if (
|
|
headers['rsc'] &&
|
|
!headers['next-router-prefetch'] &&
|
|
!headers['next-router-segment-prefetch']
|
|
) {
|
|
navigationRscRequestUrls.add(req.url())
|
|
}
|
|
})
|
|
},
|
|
})
|
|
|
|
const checkLink = async (
|
|
val: number | string,
|
|
expectedScroll: number
|
|
) => {
|
|
await browser.elementByCss(`#link-to-${val.toString()}`).click()
|
|
|
|
await retry(
|
|
() =>
|
|
expect(browser.eval('window.pageYOffset')).resolves.toEqual(
|
|
expectedScroll
|
|
),
|
|
10_000
|
|
)
|
|
}
|
|
|
|
if (isNextStart || isNextDeploy) {
|
|
await browser.waitForIdleNetwork()
|
|
}
|
|
|
|
// Wait for all network requests to finish, and then initialize the flag
|
|
// used to determine if any navigation RSC requests are made.
|
|
navigationRscRequestUrls.clear()
|
|
|
|
await checkLink(6, 128)
|
|
await checkLink(50, 744)
|
|
await checkLink(160, 2284)
|
|
await checkLink(300, 4244)
|
|
await checkLink(500, 7044) // this one is hash only (`href="#hash-500"`)
|
|
await checkLink('top', 0)
|
|
await checkLink('non-existent', 0)
|
|
|
|
if (!isNextDev) {
|
|
// Hash-only navigations must not fetch any data.
|
|
expect(Array.from(navigationRscRequestUrls)).toEqual([])
|
|
}
|
|
|
|
await checkLink('query-param', 2284)
|
|
await browser.waitForIdleNetwork()
|
|
|
|
// There should be an RSC request if the query param is changed
|
|
const hasQueryParamRscRequest = Array.from(navigationRscRequestUrls).some(
|
|
(url) => url.includes('with-query-param')
|
|
)
|
|
expect(hasQueryParamRscRequest).toBe(true)
|
|
})
|
|
|
|
it('should not scroll to hash when scroll={false} is set', async () => {
|
|
const browser = await next.browser('/hash-changes')
|
|
const curScroll = await browser.eval('document.documentElement.scrollTop')
|
|
await browser.elementByCss('#scroll-to-name-item-400-no-scroll').click()
|
|
expect(curScroll).toBe(
|
|
await browser.eval('document.documentElement.scrollTop')
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('hash-with-scroll-offset', () => {
|
|
it('should scroll to the specified hash', async () => {
|
|
const browser = await next.browser('/hash-with-scroll-offset')
|
|
|
|
const checkLink = async (
|
|
val: number | string,
|
|
expectedScroll: number
|
|
) => {
|
|
await browser.elementByCss(`#link-to-${val.toString()}`).click()
|
|
await retry(
|
|
() =>
|
|
expect(browser.eval('window.pageYOffset')).resolves.toEqual(
|
|
expectedScroll
|
|
),
|
|
10_000
|
|
)
|
|
}
|
|
|
|
await checkLink(6, 108)
|
|
await checkLink(50, 724)
|
|
await checkLink(160, 2264)
|
|
await checkLink(300, 4224)
|
|
await checkLink(500, 7024) // this one is hash only (`href="#hash-500"`)
|
|
await checkLink('top', 0)
|
|
await checkLink('non-existent', 0)
|
|
})
|
|
})
|
|
|
|
describe('hash-link-back-to-same-page', () => {
|
|
it('should scroll to the specified hash', async () => {
|
|
const browser = await next.browser('/hash-link-back-to-same-page')
|
|
|
|
const checkLink = async (
|
|
val: number | string,
|
|
expectedScroll: number
|
|
) => {
|
|
await browser.elementByCss(`#link-to-${val.toString()}`).click()
|
|
await retry(
|
|
() =>
|
|
expect(browser.eval('window.pageYOffset')).resolves.toEqual(
|
|
expectedScroll
|
|
),
|
|
10_000
|
|
)
|
|
}
|
|
|
|
await checkLink(6, 114)
|
|
await checkLink(50, 730)
|
|
await checkLink(160, 2270)
|
|
|
|
await browser
|
|
.elementByCss('#to-other-page')
|
|
// Navigate to other
|
|
.click()
|
|
// Wait for other ot load
|
|
.waitForElementByCss('#link-to-home')
|
|
// Navigate back to hash-link-back-to-same-page
|
|
.click()
|
|
// Wait for hash-link-back-to-same-page to load
|
|
.waitForElementByCss('#to-other-page')
|
|
|
|
await retry(() =>
|
|
expect(browser.eval('window.pageYOffset')).resolves.toEqual(0)
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('relative hashes and queries', () => {
|
|
const pathname = '/nested-relative-query-and-hash'
|
|
|
|
it('should work with a hash-only href', async () => {
|
|
const browser = await next.browser(pathname)
|
|
await browser.elementByCss('#link-to-h1-hash-only').click()
|
|
|
|
await retry(() =>
|
|
expect(browser.url()).resolves.toEqual(next.url + pathname + '#h1')
|
|
)
|
|
})
|
|
|
|
it('should work with a hash-only `router.push(...)`', async () => {
|
|
const browser = await next.browser(pathname)
|
|
await browser.elementByCss('#button-to-h3-hash-only').click()
|
|
|
|
await retry(() =>
|
|
expect(browser.url()).resolves.toEqual(next.url + pathname + '#h3')
|
|
)
|
|
})
|
|
|
|
it('should work with a query-only href', async () => {
|
|
const browser = await next.browser(pathname)
|
|
await browser.elementByCss('#link-to-dummy-query').click()
|
|
|
|
await retry(() =>
|
|
expect(browser.url()).resolves.toEqual(
|
|
next.url + pathname + '?foo=1&bar=2'
|
|
)
|
|
)
|
|
})
|
|
|
|
it('should work with both relative hashes and queries', async () => {
|
|
const browser = await next.browser(pathname)
|
|
await browser.elementByCss('#link-to-h2-with-hash-and-query').click()
|
|
|
|
await retry(() =>
|
|
expect(browser.url()).resolves.toEqual(
|
|
next.url + pathname + '?here=ok#h2'
|
|
)
|
|
)
|
|
|
|
// Only update hash
|
|
await browser.elementByCss('#link-to-h1-hash-only').click()
|
|
await retry(() =>
|
|
expect(browser.url()).resolves.toEqual(
|
|
next.url + pathname + '?here=ok#h1'
|
|
)
|
|
)
|
|
|
|
// Replace all with new query
|
|
await browser.elementByCss('#link-to-dummy-query').click()
|
|
await retry(() =>
|
|
expect(browser.url()).resolves.toEqual(
|
|
next.url + pathname + '?foo=1&bar=2'
|
|
)
|
|
)
|
|
|
|
// Add hash to existing query
|
|
await browser.elementByCss('#link-to-h1-hash-only').click()
|
|
await retry(() =>
|
|
expect(browser.url()).resolves.toEqual(
|
|
next.url + pathname + '?foo=1&bar=2#h1'
|
|
)
|
|
)
|
|
|
|
// Update hash again via `router.push(...)`
|
|
await browser.elementByCss('#button-to-h3-hash-only').click()
|
|
await retry(() =>
|
|
expect(browser.url()).resolves.toEqual(
|
|
next.url + pathname + '?foo=1&bar=2#h3'
|
|
)
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('not-found', () => {
|
|
it('should trigger not-found in a server component', async () => {
|
|
const browser = await next.browser('/not-found/servercomponent')
|
|
|
|
expect(
|
|
await browser.waitForElementByCss('#not-found-component').text()
|
|
).toBe('Not Found!')
|
|
expect(
|
|
await browser
|
|
.waitForElementByCss('meta[name="robots"]')
|
|
.getAttribute('content')
|
|
).toBe('noindex')
|
|
})
|
|
|
|
it('should trigger not-found in a client component', async () => {
|
|
const browser = await next.browser('/not-found/clientcomponent')
|
|
expect(
|
|
await browser.waitForElementByCss('#not-found-component').text()
|
|
).toBe('Not Found!')
|
|
expect(
|
|
await browser
|
|
.waitForElementByCss('meta[name="robots"]')
|
|
.getAttribute('content')
|
|
).toBe('noindex')
|
|
})
|
|
it('should trigger not-found client-side', async () => {
|
|
const browser = await next.browser('/not-found/client-side')
|
|
await browser
|
|
.elementByCss('button')
|
|
.click()
|
|
.waitForElementByCss('#not-found-component')
|
|
expect(await browser.elementByCss('#not-found-component').text()).toBe(
|
|
'Not Found!'
|
|
)
|
|
expect(
|
|
await browser
|
|
.waitForElementByCss('meta[name="robots"]')
|
|
.getAttribute('content')
|
|
).toBe('noindex')
|
|
})
|
|
it('should trigger not-found while streaming', async () => {
|
|
const browser = await next.browser('/not-found/suspense')
|
|
expect(
|
|
await browser.waitForElementByCss('#not-found-component').text()
|
|
).toBe('Not Found!')
|
|
expect(
|
|
await browser
|
|
.waitForElementByCss('meta[name="robots"]')
|
|
.getAttribute('content')
|
|
).toBe('noindex')
|
|
})
|
|
})
|
|
|
|
describe('redirect', () => {
|
|
describe('components', () => {
|
|
it('should redirect in a server component', async () => {
|
|
const browser = await next.browser('/redirect/servercomponent')
|
|
await browser.waitForElementByCss('#result-page')
|
|
expect(await browser.elementByCss('#result-page').text()).toBe(
|
|
'Result Page'
|
|
)
|
|
})
|
|
|
|
it('should redirect in a client component', async () => {
|
|
const browser = await next.browser('/redirect/clientcomponent')
|
|
await browser.waitForElementByCss('#result-page')
|
|
expect(await browser.elementByCss('#result-page').text()).toBe(
|
|
'Result Page'
|
|
)
|
|
})
|
|
|
|
it('should redirect client-side', async () => {
|
|
const browser = await next.browser('/redirect/client-side')
|
|
await browser
|
|
.elementByCss('button')
|
|
.click()
|
|
.waitForElementByCss('#result-page')
|
|
expect(await browser.elementByCss('#result-page').text()).toBe(
|
|
'Result Page'
|
|
)
|
|
})
|
|
|
|
it('should redirect to external url', async () => {
|
|
const browser = await next.browser('/redirect/external')
|
|
expect(await browser.waitForElementByCss('h1').text()).toBe(
|
|
'Example Domain'
|
|
)
|
|
})
|
|
|
|
it('should redirect to external url, initiating only once', async () => {
|
|
const storageKey = Math.random()
|
|
const browser = await next.browser(
|
|
`/redirect/external-log/${storageKey}`
|
|
)
|
|
expect(await browser.waitForElementByCss('h1').text()).toBe(
|
|
'Example Domain'
|
|
)
|
|
|
|
// Now check the logs...
|
|
await browser.get(
|
|
`${next.url}/redirect/external-log/${storageKey}?read=1`
|
|
)
|
|
const stored = JSON.parse(await browser.elementByCss('pre').text())
|
|
|
|
if (stored['navigation-supported'] === 'false') {
|
|
// Old browser. Can't know how many times we navigated. Oh well.
|
|
return
|
|
}
|
|
|
|
expect(stored['navigation-supported']).toEqual('true')
|
|
|
|
// This one is a bit flaky during dev, original notes by @sophiebits:
|
|
// > Not actually sure why this is '2' in dev. Possibly something
|
|
// > related to an update triggered by <HotReload>?
|
|
expect(stored['navigate-https://example.vercel.sh/']).toBeOneOf(
|
|
isNextDev ? ['1', '2'] : ['1']
|
|
)
|
|
})
|
|
|
|
it.each(['/redirect/servercomponent', 'redirect/redirect-with-loading'])(
|
|
'should only trigger the redirect once (%s)',
|
|
async (path) => {
|
|
const requestedPathnames: string[] = []
|
|
|
|
const browser = await next.browser(path, {
|
|
beforePageLoad(page) {
|
|
page.on('request', async (req) => {
|
|
requestedPathnames.push(new URL(req.url()).pathname)
|
|
})
|
|
},
|
|
})
|
|
|
|
const initialTimestamp = await browser
|
|
.waitForElementByCss('#timestamp')
|
|
.text()
|
|
|
|
let attempts = 0
|
|
const maxAttempts = 5
|
|
|
|
try {
|
|
// this ensures the timestamp remains "stable" (ie, we didn't trigger another redirect)
|
|
await retry(async () => {
|
|
const currentTimestamp = await browser
|
|
.elementByCss('#timestamp')
|
|
.text()
|
|
|
|
attempts++
|
|
|
|
// If the timestamp has changed, throw immediately.
|
|
if (currentTimestamp !== initialTimestamp) {
|
|
throw new Error(
|
|
`Timestamp has changed from the initial '${initialTimestamp}' to '${currentTimestamp}'`
|
|
)
|
|
}
|
|
|
|
// If we've reached the last attempt without the timestamp changing, force a retry failure to keep going.
|
|
if (attempts < maxAttempts) {
|
|
throw new Error('Forcing continue')
|
|
}
|
|
})
|
|
} catch (err) {
|
|
// If we catch the "Forcing continue" error, it means our condition held until the end.
|
|
// If it's a different error (i.e., the timestamp changed), we rethrow it.
|
|
if (err.message !== 'Forcing continue') {
|
|
throw err // Rethrow if the error is not our "force continue" error.
|
|
}
|
|
// If it's our "forcing continue" error, do nothing. This means we succeeded.
|
|
}
|
|
|
|
// Ensure the redirect target page was only requested once.
|
|
expect(
|
|
requestedPathnames.filter(
|
|
(pathname) => pathname === '/redirect/result'
|
|
)
|
|
).toHaveLength(1)
|
|
}
|
|
)
|
|
})
|
|
|
|
describe('next.config.js redirects', () => {
|
|
it('should redirect from next.config.js', async () => {
|
|
const browser = await next.browser('/redirect/a')
|
|
expect(await browser.elementByCss('h1').text()).toBe('redirect-dest')
|
|
expect(await browser.url()).toBe(next.url + '/redirect-dest')
|
|
})
|
|
|
|
it('should redirect from next.config.js with link navigation', async () => {
|
|
const browser = await next.browser('/redirect/next-config-redirect')
|
|
await browser
|
|
.elementByCss('#redirect-a')
|
|
.click()
|
|
.waitForElementByCss('h1')
|
|
expect(await browser.elementByCss('h1').text()).toBe('redirect-dest')
|
|
expect(await browser.url()).toBe(next.url + '/redirect-dest')
|
|
})
|
|
})
|
|
|
|
describe('middleware redirects', () => {
|
|
it('should redirect from middleware', async () => {
|
|
const browser = await next.browser('/redirect-middleware-to-dashboard')
|
|
expect(await browser.elementByCss('h1').text()).toBe('redirect-dest')
|
|
expect(await browser.url()).toBe(next.url + '/redirect-dest')
|
|
})
|
|
|
|
it('should redirect from middleware with link navigation', async () => {
|
|
const browser = await next.browser('/redirect/next-middleware-redirect')
|
|
await browser
|
|
.elementByCss('#redirect-middleware')
|
|
.click()
|
|
.waitForElementByCss('h1')
|
|
expect(await browser.elementByCss('h1').text()).toBe('redirect-dest')
|
|
expect(await browser.url()).toBe(next.url + '/redirect-dest')
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('external push', () => {
|
|
it('should push external url without affecting hooks', async () => {
|
|
// Log with sessionStorage to persist across navigations
|
|
const storageKey = Math.random()
|
|
const browser = await next.browser(`/external-push/${storageKey}`)
|
|
await browser.elementByCss('#go').click()
|
|
await browser.waitForCondition(
|
|
'window.location.origin === "https://example.vercel.sh"'
|
|
)
|
|
|
|
// Now check the logs...
|
|
await browser.get(`${next.url}/external-push/${storageKey}`)
|
|
const stored = JSON.parse(await browser.elementByCss('pre').text())
|
|
let expected = {
|
|
// Only one navigation
|
|
'navigate-https://example.vercel.sh/stuff?abc=123': '1',
|
|
'navigation-supported': 'true',
|
|
// Make sure /stuff?abc=123 is not logged here
|
|
[`path-/external-push/${storageKey}`]: 'true',
|
|
// isPending should have been true until the page unloads
|
|
lastIsPending: 'true',
|
|
}
|
|
|
|
if (stored['navigation-supported'] !== 'true') {
|
|
// Old browser. Can't know how many times we navigated. Oh well.
|
|
expected['navigation-supported'] = 'false'
|
|
for (const key in expected) {
|
|
if (key.startsWith('navigate-')) {
|
|
delete expected[key]
|
|
}
|
|
}
|
|
}
|
|
|
|
expect(stored).toEqual(expected)
|
|
})
|
|
})
|
|
|
|
describe('navigation between pages and app', () => {
|
|
it('should not contain _rsc query while navigating from app to pages', async () => {
|
|
// Initiate with app
|
|
const browser = await next.browser('/assertion/page')
|
|
await browser
|
|
.elementByCss('#link-to-pages')
|
|
.click()
|
|
.waitForElementByCss('#link-to-app')
|
|
expect(await browser.url()).toBe(next.url + '/some')
|
|
await browser
|
|
.elementByCss('#link-to-app')
|
|
.click()
|
|
.waitForElementByCss('#link-to-pages')
|
|
expect(await browser.url()).toBe(next.url + '/assertion/page')
|
|
})
|
|
|
|
it('should not contain _rsc query while navigating from pages to app', async () => {
|
|
// Initiate with pages
|
|
const browser = await next.browser('/some')
|
|
await browser
|
|
.elementByCss('#link-to-app')
|
|
.click()
|
|
.waitForElementByCss('#link-to-pages')
|
|
expect(await browser.url()).toBe(next.url + '/assertion/page')
|
|
await browser
|
|
.elementByCss('#link-to-pages')
|
|
.click()
|
|
.waitForElementByCss('#link-to-app')
|
|
expect(await browser.url()).toBe(next.url + '/some')
|
|
})
|
|
|
|
it('should not omit the hash while navigating from app to pages', async () => {
|
|
const browser = await next.browser('/hash-link-to-pages-router')
|
|
await browser
|
|
.elementByCss('#link-to-pages-router')
|
|
.click()
|
|
.waitForElementByCss('#link-to-app')
|
|
await retry(() =>
|
|
expect(browser.url()).resolves.toEqual(next.url + '/some#non-existent')
|
|
)
|
|
})
|
|
|
|
if (!isNextDev) {
|
|
// this test is pretty hard to test in playwright, so most of the heavy lifting is in the page component itself
|
|
// it triggers a hover on a link to initiate a prefetch request every second, and so we check that
|
|
// it doesn't repeatedly initiate the mpa navigation request
|
|
it('should not continously initiate a mpa navigation to the same URL when router state changes', async () => {
|
|
let requestCount = 0
|
|
await next.browser('/mpa-nav-test', {
|
|
beforePageLoad(page) {
|
|
page.on('request', (request) => {
|
|
const url = new URL(request.url())
|
|
// skip rsc prefetches
|
|
if (url.pathname === '/slow-page' && !url.search) {
|
|
requestCount++
|
|
}
|
|
})
|
|
},
|
|
})
|
|
|
|
// wait a few seconds since prefetches are triggered in 1s intervals in the page component
|
|
await waitFor(5000)
|
|
|
|
expect(requestCount).toBe(1)
|
|
})
|
|
}
|
|
})
|
|
|
|
describe('nested navigation', () => {
|
|
it('should navigate to nested pages', async () => {
|
|
const browser = await next.browser('/nested-navigation')
|
|
expect(await browser.elementByCss('h1').text()).toBe('Home')
|
|
|
|
const pages = [
|
|
['Electronics', ['Phones', 'Tablets', 'Laptops']],
|
|
['Clothing', ['Tops', 'Shorts', 'Shoes']],
|
|
['Books', ['Fiction', 'Biography', 'Education']],
|
|
] as const
|
|
|
|
for (const [category, subCategories] of pages) {
|
|
expect(
|
|
await browser
|
|
.elementByCss(
|
|
`a[href="/nested-navigation/${category.toLowerCase()}"]`
|
|
)
|
|
.click()
|
|
.waitForElementByCss(`#all-${category.toLowerCase()}`)
|
|
.text()
|
|
).toBe(`All ${category}`)
|
|
|
|
for (const subcategory of subCategories) {
|
|
expect(
|
|
await browser
|
|
.elementByCss(
|
|
`a[href="/nested-navigation/${category.toLowerCase()}/${subcategory.toLowerCase()}"]`
|
|
)
|
|
.click()
|
|
.waitForElementByCss(`#${subcategory.toLowerCase()}`)
|
|
.text()
|
|
).toBe(`${subcategory}`)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('should load chunks correctly without double encoding of url', async () => {
|
|
const browser = await next.browser('/router')
|
|
|
|
await browser
|
|
.elementByCss('#dynamic-link')
|
|
.click()
|
|
.waitForElementByCss('#dynamic-gsp-content')
|
|
|
|
expect(await browser.elementByCss('#dynamic-gsp-content').text()).toBe(
|
|
'slug:1'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('SEO', () => {
|
|
it('should emit noindex meta tag for not found page when streaming', async () => {
|
|
const noIndexTag = '<meta name="robots" content="noindex"/>'
|
|
const defaultViewportTag =
|
|
'<meta name="viewport" content="width=device-width, initial-scale=1"/>'
|
|
const devErrorMetadataTag =
|
|
'<meta name="next-error" content="not-found"/>'
|
|
const html = await next.render('/not-found/suspense')
|
|
|
|
expect(html).toContain(noIndexTag)
|
|
// only contain once
|
|
expect(html.split(noIndexTag).length).toBe(2)
|
|
expect(html.split(defaultViewportTag).length).toBe(2)
|
|
if (isNextDev) {
|
|
// only contain dev error tag once
|
|
expect(html.split(devErrorMetadataTag).length).toBe(2)
|
|
}
|
|
})
|
|
|
|
it('should emit refresh meta tag for redirect page when streaming', async () => {
|
|
const html = await next.render('/redirect/suspense')
|
|
expect(html).toContain(
|
|
'<meta id="__next-page-redirect" http-equiv="refresh" content="1;url=/redirect/result"/>'
|
|
)
|
|
})
|
|
|
|
it('should emit refresh meta tag (permanent) for redirect page when streaming', async () => {
|
|
const html = await next.render('/redirect/suspense-2')
|
|
expect(html).toContain(
|
|
'<meta id="__next-page-redirect" http-equiv="refresh" content="0;url=/redirect/result"/>'
|
|
)
|
|
})
|
|
|
|
it('should contain default meta tags in error page', async () => {
|
|
const html = await next.render('/not-found/servercomponent')
|
|
expect(html).toContain('<meta name="robots" content="noindex"/>')
|
|
expect(html).toContain(
|
|
'<meta name="viewport" content="width=device-width, initial-scale=1"/>'
|
|
)
|
|
})
|
|
|
|
it('should not log 404 errors in ipc server', async () => {
|
|
await next.fetch('/this-path-does-not-exist')
|
|
expect(next.cliOutput).not.toInclude(
|
|
'PageNotFoundError: Cannot find module for page'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('navigations when attaching a Proxy to `window.Promise`', () => {
|
|
it('should navigate without issue', async () => {
|
|
const browser = await next.browser('/nested-navigation')
|
|
await browser.eval(`window.Promise = new Proxy(window.Promise, {})`)
|
|
|
|
expect(await browser.elementByCss('h1').text()).toBe('Home')
|
|
|
|
const pages = [
|
|
['Electronics', ['Phones', 'Tablets', 'Laptops']],
|
|
['Clothing', ['Tops', 'Shorts', 'Shoes']],
|
|
['Books', ['Fiction', 'Biography', 'Education']],
|
|
['Shoes', []],
|
|
] as const
|
|
|
|
for (const [category, subCategories] of pages) {
|
|
expect(
|
|
await browser
|
|
.elementByCss(
|
|
`a[href="/nested-navigation/${category.toLowerCase()}"]`
|
|
)
|
|
.click()
|
|
.waitForElementByCss(`#all-${category.toLowerCase()}`)
|
|
.text()
|
|
).toBe(`All ${category}`)
|
|
|
|
for (const subcategory of subCategories) {
|
|
expect(
|
|
await browser
|
|
.elementByCss(
|
|
`a[href="/nested-navigation/${category.toLowerCase()}/${subcategory.toLowerCase()}"]`
|
|
)
|
|
.click()
|
|
.waitForElementByCss(`#${subcategory.toLowerCase()}`)
|
|
.text()
|
|
).toBe(`${subcategory}`)
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('scroll restoration', () => {
|
|
it('should restore original scroll position when navigating back', async () => {
|
|
const browser = await next.browser('/scroll-restoration', {
|
|
// throttling the CPU to rule out flakiness based on how quickly the page loads
|
|
cpuThrottleRate: 6,
|
|
})
|
|
|
|
const body = await browser.elementByCss('body')
|
|
expect(await body.text()).toContain('Item 50')
|
|
await browser.elementById('load-more').click()
|
|
await browser.elementById('load-more').click()
|
|
await browser.elementById('load-more').click()
|
|
expect(await body.text()).toContain('Item 200')
|
|
|
|
// scroll to the bottom of the page
|
|
await browser.eval('window.scrollTo(0, document.body.scrollHeight)')
|
|
|
|
// grab the current position
|
|
const scrollPosition = await browser.eval('window.pageYOffset')
|
|
|
|
await browser.elementByCss("[href='/scroll-restoration/other']").click()
|
|
await retry(async () => {
|
|
await browser.elementById('back-button').click()
|
|
})
|
|
|
|
const newScrollPosition = await browser.eval('window.pageYOffset')
|
|
|
|
// confirm that the scroll position was restored
|
|
expect(newScrollPosition).toEqual(scrollPosition)
|
|
})
|
|
})
|
|
|
|
describe('navigating to a page with async metadata', () => {
|
|
it('shows a fallback when prefetch was pending', async () => {
|
|
const resolveMetadataDuration = 5000
|
|
const browser = await next.browser('/metadata-await-promise')
|
|
|
|
// Hopefully this click happened before the prefetch was completed.
|
|
// TODO: Programmatically trigger prefetch e.g. by mounting the link later.
|
|
await browser
|
|
.elementByCss("[href='/metadata-await-promise/nested']")
|
|
.click()
|
|
|
|
await waitFor(resolveMetadataDuration + 500)
|
|
|
|
expect(await browser.elementById('page-content').text()).toBe('Content')
|
|
expect(await getTitle(browser)).toBe('Async Title')
|
|
})
|
|
|
|
it('shows a fallback when prefetch completed', async () => {
|
|
const resolveMetadataDuration = 5000
|
|
const browser = await next.browser('/metadata-await-promise')
|
|
|
|
if (!isNextDev) {
|
|
await waitFor(resolveMetadataDuration + 500)
|
|
}
|
|
|
|
await browser
|
|
.elementByCss("[href='/metadata-await-promise/nested']")
|
|
.click()
|
|
|
|
if (!isNextDev) {
|
|
expect(
|
|
await browser
|
|
.waitForElementByCss('title', resolveMetadataDuration + 500)
|
|
.text()
|
|
).toBe('Async Title')
|
|
}
|
|
|
|
expect(await browser.elementById('page-content').text()).toBe('Content')
|
|
})
|
|
})
|
|
|
|
describe('navigating to dynamic params & changing the casing', () => {
|
|
it('should load the page correctly', async () => {
|
|
const browser = await next.browser('/dynamic-param-casing-change')
|
|
|
|
// note the casing here capitalizes `ParamA`
|
|
await browser
|
|
.elementByCss("[href='/dynamic-param-casing-change/ParamA']")
|
|
.click()
|
|
|
|
// note the `paramA` casing has now changed
|
|
await browser
|
|
.elementByCss("[href='/dynamic-param-casing-change/paramA/noParam']")
|
|
.click()
|
|
|
|
await retry(async () => {
|
|
expect(await browser.elementByCss('body').text()).toContain(
|
|
'noParam page'
|
|
)
|
|
})
|
|
|
|
await browser.back()
|
|
|
|
await browser
|
|
.elementByCss("[href='/dynamic-param-casing-change/paramA/paramB']")
|
|
.click()
|
|
|
|
await retry(async () => {
|
|
expect(await browser.elementByCss('body').text()).toContain(
|
|
'[paramB] page'
|
|
)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('browser back to a revalidated page', () => {
|
|
it('should load the page', async () => {
|
|
const browser = await next.browser('/popstate-revalidate')
|
|
expect(await browser.elementByCss('h1').text()).toBe('Home')
|
|
await browser.elementByCss("[href='/popstate-revalidate/foo']").click()
|
|
await browser.waitForElementByCss('#submit-button')
|
|
expect(await browser.elementByCss('h1').text()).toBe('Form')
|
|
await browser.elementById('submit-button').click()
|
|
|
|
await retry(async () => {
|
|
expect(await browser.elementByCss('body').text()).toContain(
|
|
'Form Submitted.'
|
|
)
|
|
})
|
|
|
|
await browser.back()
|
|
|
|
await retry(async () => {
|
|
expect(await browser.elementByCss('h1').text()).toBe('Home')
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('browser back after a push followed by a refresh', () => {
|
|
it('should load the previous page', async () => {
|
|
const browser = await next.browser('/push-and-refresh')
|
|
expect(await browser.elementByCss('h1').text()).toBe('Home')
|
|
const historyLength = await browser.eval('window.history.length')
|
|
|
|
await browser.elementById('push-and-refresh').click()
|
|
|
|
await retry(async () => {
|
|
expect(await browser.elementByCss('h1').text()).toBe('Target')
|
|
})
|
|
await retry(async () => {
|
|
expect(await browser.eval('window.history.length')).toBe(
|
|
historyLength + 1
|
|
)
|
|
})
|
|
|
|
await browser.back()
|
|
|
|
await retry(async () => {
|
|
expect(await browser.elementByCss('h1').text()).toBe('Home')
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('replace during a push that has not committed', () => {
|
|
it('should supersede the pending push', async () => {
|
|
const browser = await next.browser('/push-and-replace')
|
|
// Compile the destination pages so the push settles before the replace.
|
|
await browser.eval(
|
|
`Promise.all([
|
|
fetch('/push-and-replace/suspended'),
|
|
fetch('/push-and-replace/other'),
|
|
]).then(() => 'ok')`
|
|
)
|
|
expect(await browser.elementByCss('h1').text()).toBe('Home')
|
|
const historyLength = await browser.eval('window.history.length')
|
|
|
|
await browser.elementById('push-and-replace').click()
|
|
|
|
await retry(async () => {
|
|
expect(await browser.elementByCss('h1').text()).toBe('Other')
|
|
})
|
|
expect(await browser.url()).toContain('/push-and-replace/other')
|
|
expect(await browser.eval('window.history.length')).toBe(historyLength)
|
|
})
|
|
})
|
|
|
|
describe('middleware redirect', () => {
|
|
it('should change browser location when router.refresh() gets a redirect response', async () => {
|
|
const browser = await next.browser('/redirect-on-refresh/auth')
|
|
await retry(async () =>
|
|
expect(await browser.url()).toBe(
|
|
next.url + '/redirect-on-refresh/dashboard'
|
|
)
|
|
)
|
|
})
|
|
})
|
|
|
|
if (isNextDev) {
|
|
describe('locale warnings', () => {
|
|
it('should warn about using the `locale` prop with `next/link` in app router', async () => {
|
|
const browser = await next.browser('/locale-app')
|
|
|
|
await retry(async () => {
|
|
const logs = await browser.log()
|
|
expect(logs).toContainEqual(
|
|
expect.objectContaining({
|
|
message: expect.stringContaining(
|
|
'The `locale` prop is not supported in `next/link` while using the `app` router.'
|
|
),
|
|
source: 'warning',
|
|
})
|
|
)
|
|
})
|
|
})
|
|
|
|
it('should have no warnings in pages router', async () => {
|
|
const browser = await next.browser('/locale-pages')
|
|
const logs = await browser.log()
|
|
expect(logs.filter((log) => log.source === 'warning')).toHaveLength(0)
|
|
})
|
|
})
|
|
}
|
|
|
|
describe('useRouter identity between navigations', () => {
|
|
it('should preserve identity when navigating to the same page', async () => {
|
|
const browser = await next.browser('/use-router/same-page')
|
|
|
|
expect(await browser.elementByCss('#count-from-server').text()).toBe('0')
|
|
expect(
|
|
await browser.elementByCss('#count-from-client-state').text()
|
|
).toBe('0')
|
|
expect(await browser.elementByCss('#router-change-count').text()).toBe(
|
|
'0'
|
|
)
|
|
|
|
for (let i = 1; i <= 3; i++) {
|
|
await browser.elementByCss('#trigger-push').click()
|
|
await retry(async () => {
|
|
expect(await browser.elementByCss('#count-from-server').text()).toBe(
|
|
`${i}`
|
|
)
|
|
// the client state is independent from the count we keep in the queryparam.
|
|
// we expect it to stay mounted and thus keep its own count.
|
|
// if it was getting unmounted, then its count of router changes would always stay at 0.
|
|
expect(
|
|
await browser.elementByCss('#count-from-client-state').text()
|
|
).toBe(`${i}`)
|
|
expect(
|
|
await browser.elementByCss('#router-change-count').text()
|
|
).toBe('0')
|
|
})
|
|
}
|
|
})
|
|
|
|
it('should preserve identity when navigating between different pages', async () => {
|
|
const browser = await next.browser('/use-router/shared-layout/one')
|
|
|
|
expect(await browser.elementByCss('h1').text()).toBe('One')
|
|
expect(
|
|
await browser.elementByCss('#count-from-client-state').text()
|
|
).toBe('0')
|
|
expect(await browser.elementByCss('#router-change-count').text()).toBe(
|
|
'0'
|
|
)
|
|
|
|
for (let i = 1; i <= 3; i++) {
|
|
await browser.elementByCss('#trigger-push').click()
|
|
await retry(async () => {
|
|
expect(await browser.elementByCss('h1').text()).toBe(
|
|
i % 2 === 0 ? 'One' : 'Two'
|
|
)
|
|
// we expect the client part to stay mounted and thus keep its own count.
|
|
// if it was getting unmounted, then its count of router changes would always be 0.
|
|
expect(
|
|
await browser.elementByCss('#count-from-client-state').text()
|
|
).toBe(`${i}`)
|
|
expect(
|
|
await browser.elementByCss('#router-change-count').text()
|
|
).toBe('0')
|
|
})
|
|
}
|
|
})
|
|
})
|
|
})
|