Files
vercel__next.js/test/lib/router-act.ts
Andrew Clark 12b802b259 Convert per-segment prefetches to NavigationFlightResponse format (#96877)
This is the last of the changes to convert all RSC requests to a single
unified response type, NavigationFlightResponse.

The old format was a positional array whose ordering had to be kept in
sync by hand between the server and the client. The new format is the
same root-anchored tree used by every other response, so the dedicated
client write path for per-segment responses is deleted and these
responses go through the shared one.

A few functions are renamed as their roles change:
convertServerPatchToFullTree becomes createNavigationSeed (it now also
accepts trees with no base to overlay), fetchSegmentsOnCacheMiss becomes
fetchSegmentPrefetchesUsingStaticRequest (the counterpart of
fetchSegmentPrefetchesUsingRuntimeRequest), and writeSeedDataIntoCache
becomes writeTreeDataIntoCache (seed data no longer exists as a
concept).

During the unification, a few inconsistencies and oversights were caught
and fixed: response data with no matching pending entry is written as a
detached entry instead of dropped, and a response's byte size is spread
across the entries it actually fulfilled rather than the old chain-based
count. The fallback retry loop also marks itself pending before the
first cache write instead of after, closing a window where the scheduler
could spawn a duplicate revalidation.

The second commit unifies cache keying for prefetched segments. There
were two implementations of the logic that decides which cache key a
prefetched segment is written to, one for per-segment responses and one
for responses produced by a live render. Now there's one: if the server
reported which params the segment depends on, key the entry using those;
otherwise fall back to the keying implied by how the payload was
fetched. A segment is only treated as param-independent when the server
says so. Two precedence fixes were needed to merge them, and a few other
inconsistencies found during the unification are fixed along the way;
see the commit message for details.
2026-08-13 18:18:23 -04:00

870 lines
36 KiB
TypeScript

import type * as Playwright from 'playwright'
import { diff } from 'jest-diff'
import { equals } from '@jest/expect-utils'
// Mirrors NEXT_ROUTER_PREFETCH_HEADER from the Next.js client. App Shell
// prefetches carry the value '3' (FetchStrategy.RuntimeShell). The App Shell is
// the param/searchParam-independent chrome of a route — conceptually part of the
// route itself, not prefetch data in the way we normally think of it. By default
// we therefore exclude App Shell requests from `act` assertion logic
// (`includes` matching, `no-requests`, and `block: 'reject'`). They are still
// intercepted, fulfilled, and awaited so that the browser caches the shell and
// no requests are left in flight — and they do satisfy the "at least one
// request" check, since they prove the router reacted to the scope. Pass
// `includeAppShellRequests: true` to `createRouterAct` to assert on them
// directly (e.g. when testing App Shell behavior specifically).
const NEXT_ROUTER_PREFETCH_HEADER = 'next-router-prefetch'
const APP_SHELL_PREFETCH_VALUE = '3'
// These headers/values classify each intercepted router request by the kind
// of prefetch protocol it uses, so that expectations can assert on it via
// the `kind` option:
//
// - 'static': per-segment static prefetches. These carry the
// `next-router-segment-prefetch` header (NEXT_ROUTER_SEGMENT_PREFETCH_HEADER
// in the client), which is sent both by the per-segment data fetch
// (`fetchSegmentPrefetchesUsingStaticRequest`) and the route tree fetch
// (`fetchRouteOnCacheMiss`).
// - 'runtime': dynamic prefetch requests, issued by
// `fetchSegmentPrefetchesUsingRuntimeRequest` in the client. These carry a
// FlightRouterState request tree and a `next-router-prefetch` header value
// of '2' (FetchStrategy.PPRRuntime) or '3' (FetchStrategy.RuntimeShell).
// The other strategies used by that path — LoadingBoundary ('1') and Full
// (no prefetch header) — send the same headers as plain prefetches and
// navigations respectively, so they cannot be demonstrably classified and
// are left unclassified.
// - undefined: everything else — navigations, Server Actions, and plain
// prefetches. Expectations that specify a `kind` never claim these.
const NEXT_ROUTER_SEGMENT_PREFETCH_HEADER = 'next-router-segment-prefetch'
const PPR_RUNTIME_PREFETCH_VALUE = '2'
type ResponseKind = 'static' | 'runtime'
type Batch = {
pendingRequestChecks: Set<Promise<void>>
pendingRequests: Set<PendingRSCRequest>
// Whether any router request — including an App Shell request — was
// received by this batch. Used by the "at least one request" watchdog:
// any router request proves the router reacted to the `act` scope, even
// ones that are excluded from the assertion logic. Also set when an inner
// `act`'s blocked responses are transferred to this batch.
didReceiveRouterRequest: boolean
}
type PendingRSCRequest = {
url: string
route: Playwright.Route | null
result: Promise<{
text: string
body: any
headers: Record<string, string>
status: number
}>
didProcess: boolean
// True if this is an App Shell prefetch request that should be ignored for
// assertion purposes (see note above). Always false when the caller passes
// `includeAppShellRequests: true`.
isAppShell: boolean
// The kind of prefetch protocol this request uses, derived from its headers
// (see the classification note above). undefined for navigations, Server
// Actions, and plain prefetches.
kind: ResponseKind | undefined
}
let currentBatch: Batch | null = null
type ExpectedResponseConfig = {
includes: string
block?: boolean | 'reject'
kind?: ResponseKind
}
/**
* Represents the expected responses sent by the server to fulfill requests
* initiated by the `scope` function.
*
* - `includes` is a substring of an expected response body.
* - `block` indicates whether the response should not yet be sent to the
* client. This option is only supported when nested inside an outer `act`
* scope. The blocked response will be fulfilled when the outer
* scope completes.
* - `kind` restricts which responses can satisfy the expectation, based on
* the kind of prefetch request that produced them: 'static' matches only
* per-segment static prefetches (those with a `next-router-segment-prefetch`
* request header), and 'runtime' matches only dynamic prefetch requests
* (those with a `next-router-prefetch` header value of '2' (PPRRuntime) or
* '3' (RuntimeShell)). If omitted, any router response can satisfy the
* expectation. Note that App Shell (RuntimeShell) responses are excluded
* from assertion logic by default, so a `kind: 'runtime'` expectation that
* should match an App Shell response additionally requires passing
* `includeAppShellRequests: true` to `createRouterAct`.
*
* The list of expected responses does not need to be exhaustive — any
* responses that don't match will proceed like normal. However, `act` will
* error if the expected substring is not found in any of the responses, or
* if the expected responses are received out of order. It will also error
* if the same expected substring is found in multiple responses.
*
* If no expected responses are provided, the only expectation is that at
* least one request is initiated. (This is the same as passing an
* empty array.)
*
* Alternatively, if no network activity is expected, pass "no-requests".
*/
type ActConfig =
| ExpectedResponseConfig
| Array<ExpectedResponseConfig>
| 'block'
| 'no-requests'
| null
export function createRouterAct(
page: Playwright.Page,
options?: {
/**
* Status codes that are allowed to be returned by the server. If not
* provided, all error status codes are disallowed (400+).
*/
allowErrorStatusCodes?: number[]
/**
* By default, App Shell prefetch requests (those with a
* `next-router-prefetch: '3'` header) are ignored for the purposes of
* assertion matching, `no-requests`, `block: 'reject'`, and the "at least
* one request" check. They are still intercepted, fulfilled, and awaited.
*
* Set this to `true` to treat App Shell requests like any other router
* request. Use this when writing tests for App Shell behavior specifically.
*/
includeAppShellRequests?: boolean
}
): <T>(scope: () => Promise<T> | T, config?: ActConfig) => Promise<T> {
const includeAppShellRequests = options?.includeAppShellRequests ?? false
// Track App Shell requests that are currently in flight anywhere on the
// page — including ones initiated outside an `act` scope, like viewport
// prefetches during the initial page load. The "at least one request"
// watchdog consults this set: the prefetch scheduler's Shell phase does not
// complete until its shell responses have arrived, so while a shell request
// is in flight, follow-up requests (which ARE countable) may be
// legitimately waiting on it. In that case the watchdog keeps waiting
// instead of timing out. See the watchdog logic inside `act`.
const inFlightAppShellRequests = new Set<Playwright.Request>()
const onRequestStarted = (request: Playwright.Request) => {
if (
request.headers()[NEXT_ROUTER_PREFETCH_HEADER] ===
APP_SHELL_PREFETCH_VALUE
) {
inFlightAppShellRequests.add(request)
}
}
const onRequestSettled = (request: Playwright.Request) => {
inFlightAppShellRequests.delete(request)
}
// A hard navigation can orphan in-flight requests: Playwright doesn't
// reliably emit `requestfinished`/`requestfailed` for requests aborted by
// page teardown, which would leave stale entries in the set for the rest
// of the test — and a stale entry keeps the watchdog alive forever,
// turning what should be a clean timeout error into a hang. The destroyed
// document's shell requests can't gate anything anymore, and the new
// document's requests re-add themselves, so drop them all.
const onFrameDetached = () => {
inFlightAppShellRequests.clear()
}
page.on('request', onRequestStarted)
page.on('requestfinished', onRequestSettled)
page.on('requestfailed', onRequestSettled)
page.on('framedetached', onFrameDetached)
/**
* Helper function to wait for requestIdleCallback with retry logic.
* Retries up to 3 times if "Execution context was destroyed" error occurs.
*/
async function waitForIdleCallback(): Promise<void> {
const maxRetries = 3
const retryDelayMs = 100
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
await page.evaluate(
() =>
new Promise<void>((res) =>
requestIdleCallback(() => res(), {
// Add a timeout option to prevents the callback from being
// backgrounded indefinitely. Not sure why this happens but
// without it, the callback will never fire.
//
// Note that this does not delay the callback from firing.
// It should still fire pretty much "immediately". It's just a
// safeguard in case the idle callback queue is not fired within
// a reasonable amount of time. It really shouldn't
// be necessary.
//
// TODO: I'm getting increasingly frustrated by how flaky
// Playwright's APIs are. At this point I'm convinced we should
// rewrite the whole router-act module to an equivalent
// implementation that runs directly in the browser, by
// injecting a script into the page. Since we only use it for
// our own contrived e2e test apps, we can just import the
// script into each test app that needs it.
timeout: 100,
})
)
)
return
} catch (err) {
const isLastAttempt = attempt === maxRetries - 1
const isExecutionContextError =
err instanceof Error &&
err.message.includes('Execution context was destroyed')
if (isExecutionContextError && !isLastAttempt) {
await new Promise((resolve) => setTimeout(resolve, retryDelayMs))
continue
}
throw err
}
}
}
/**
* Test utility for requests initiated by the Next.js Router, such as
* prefetches and navigations. Calls the given async function then intercepts
* any router requests that are initiated as a result. It will then wait for
* all the requests to complete before exiting. Inspired by the React
* `act` API.
*/
async function act<T>(
scope: () => Promise<T> | T,
config?: ActConfig
): Promise<T> {
// Capture a stack trace for better async error messages.
const error = new Error()
if (Error.captureStackTrace) {
Error.captureStackTrace(error, act)
}
let expectedResponses: Array<ExpectedResponseConfig> | null
let forbiddenResponses: Array<ExpectedResponseConfig> | null = null
let shouldBlockAll = false
const allowStatuses = options?.allowErrorStatusCodes ?? null
if (config === undefined || config === null) {
// Default. Expect at least one request, but don't assert on the response.
expectedResponses = []
} else if (config === 'block') {
// Expect at least one request, and block them all from being fulfilled.
if (currentBatch === null) {
error.message =
'`block` option only supported when nested inside an outer ' +
'`act` scope.'
throw error
}
expectedResponses = []
shouldBlockAll = true
} else if (config === 'no-requests') {
// Expect no requests to be initiated.
expectedResponses = null
} else if (!Array.isArray(config)) {
// Shortcut for a single expected response.
if (config.block === true && currentBatch === null) {
error.message =
'`block: true` option only supported when nested inside an outer ' +
'`act` scope.'
throw error
}
if (config.block !== 'reject') {
expectedResponses = [config]
} else {
expectedResponses = []
forbiddenResponses = [config]
}
} else {
expectedResponses = []
for (const item of config) {
if (item.block === true && currentBatch === null) {
error.message =
'`block: true` option only supported when nested inside an outer ' +
'`act` scope.'
throw error
}
if (item.block !== 'reject') {
expectedResponses.push(item)
} else {
if (forbiddenResponses === null) {
forbiddenResponses = [item]
} else {
forbiddenResponses.push(item)
}
}
}
}
// Attach a route handler to intercept router requests for the duration
// of the `act` scope. It will be removed before `act` exits.
let onDidIssueFirstRequest: (() => void) | null = null
const routeHandler = async (route: Playwright.Route) => {
const request = route.request()
const pendingRequests = batch.pendingRequests
const pendingRequestChecks = batch.pendingRequestChecks
// Because determining whether we need to intercept the request is an
// async operation, we collect these promises so we can await them at the
// end of the `act` scope to see whether any additional requests
// were initiated.
// NOTE: The default check doesn't actually need to be async, but since
// this logic is subtle, to preserve the ability to add an async
// check later, I'm treating it as if it could possibly be async.
const checkIfRouterRequest = (async () => {
const headers = request.headers()
// The default check includes navigations, prefetches, and actions.
const isRouterRequest =
headers['rsc'] !== undefined || // Matches navigations and prefetches
headers['next-action'] !== undefined // Matches Server Actions
// App Shell prefetch requests are intercepted and fulfilled like any
// other router request, but (unless the caller opts in) they don't
// participate in any assertion logic. See the note at the top of
// this file.
const isAppShell =
!includeAppShellRequests &&
headers[NEXT_ROUTER_PREFETCH_HEADER] === APP_SHELL_PREFETCH_VALUE
// Classify the request by the kind of prefetch protocol it uses, so
// expectations can assert on it via the `kind` option. See the
// classification note at the top of this file.
let kind: ResponseKind | undefined
if (headers[NEXT_ROUTER_SEGMENT_PREFETCH_HEADER] !== undefined) {
kind = 'static'
} else if (
headers[NEXT_ROUTER_PREFETCH_HEADER] === PPR_RUNTIME_PREFETCH_VALUE ||
headers[NEXT_ROUTER_PREFETCH_HEADER] === APP_SHELL_PREFETCH_VALUE
) {
kind = 'runtime'
}
if (isRouterRequest) {
// This request was initiated by the Next.js Router. Intercept it and
// add it to the current batch.
pendingRequests.add({
url: request.url(),
route,
isAppShell,
kind,
// `act` controls the timing of when responses reach the client,
// but it should not affect the timing of when requests reach the
// server; we pass the request to the server the immediately.
result: (async () => {
let originalResponse: Playwright.APIResponse
try {
originalResponse = await page.request.fetch(request, {
maxRedirects: 0,
})
} catch (fetchError) {
error.message =
fetchError instanceof Error
? fetchError.message
: String(fetchError)
throw error
}
// WORKAROUND:
// intercepting responses with 'Transfer-Encoding: chunked' (used for streaming)
// seems to be problematic sometimes, making the browser error with `net::ERR_INCOMPLETE_CHUNKED_ENCODING`.
// In particular, this seems to happen when blocking a streaming navigation response. (but not always)
// Playwright buffers the whole body anyway, so we can remove the header to sidestep this.
const headers = originalResponse.headers()
delete headers['transfer-encoding']
return {
text: await originalResponse.text(),
body: await originalResponse.body(),
headers,
status: originalResponse.status(),
}
})(),
didProcess: false,
})
// Any router request — including an App Shell request, which is
// otherwise excluded from assertion logic — satisfies the "at least
// one request" watchdog, since it proves the router reacted to the
// `act` scope.
batch.didReceiveRouterRequest = true
if (onDidIssueFirstRequest !== null) {
onDidIssueFirstRequest()
onDidIssueFirstRequest = null
}
return
}
// This is some other request not related to the Next.js Router. Allow
// it to continue as normal.
route.continue()
})()
pendingRequestChecks.add(checkIfRouterRequest)
await checkIfRouterRequest
// Once we've read the header, we can remove it from the pending set.
pendingRequestChecks.delete(checkIfRouterRequest)
}
let didHardNavigate = false
const hardNavigationHandler = async () => {
// If a hard navigation occurs, the current batch of requests is no longer
// valid. In fact, Playwright will hang indefinitely if we attempt to
// await the response of an orphaned request. Reset the batch and unblock
// all the orphaned requests.
const orphanedRequests = batch.pendingRequests
batch.pendingRequests = new Set()
batch.pendingRequestChecks = new Set()
batch.didReceiveRouterRequest = false
await Promise.all(
Array.from(orphanedRequests).map((item) => item.route?.continue())
)
didHardNavigate = true
}
const waitForPendingRequestChecks = async () => {
const prevChecks = batch.pendingRequestChecks
batch.pendingRequestChecks = new Set()
await Promise.all(prevChecks)
}
const prevBatch = currentBatch
const batch: Batch = {
pendingRequestChecks: new Set(),
pendingRequests: new Set(),
didReceiveRouterRequest: false,
}
currentBatch = batch
await page.route('**/*', routeHandler)
page.on('framedetached', hardNavigationHandler)
try {
// Call the user-provided scope function
const returnValue = await scope()
// Wait until the first request is initiated, up to some timeout.
if (expectedResponses !== null && !batch.didReceiveRouterRequest) {
await new Promise<void>((resolve, reject) => {
let timerId: ReturnType<typeof setTimeout>
const onExpiry = () => {
// Before timing out, check for App Shell requests in flight
// elsewhere on the page (e.g. spawned by a viewport prefetch
// during page load, before this `act` scope began, so it was
// never intercepted here). The prefetch scheduler's Shell phase
// doesn't complete until its shell responses arrive, so the
// first observable request may be legitimately gated on one.
// Keep the watchdog alive until it settles.
if (inFlightAppShellRequests.size > 0) {
timerId = setTimeout(onExpiry, 500)
return
}
error.message = 'Timed out waiting for a request to be initiated.'
reject(error)
}
timerId = setTimeout(onExpiry, 500)
onDidIssueFirstRequest = () => {
clearTimeout(timerId)
resolve()
}
})
}
// Fulfill all the requests that were initiated by the scope function. But
// first, wait an additional browser task. This simulates the real world
// behavior where the network response is received in an async event/task
// that comes after the scope function, rather than immediately when the
// scope function exits.
//
// We use requestIdleCallback to schedule the task because that's
// guaranteed to fire after any IntersectionObserver events, which the
// router uses to track the visibility of links.
await waitForIdleCallback()
// Checking whether a request needs to be intercepted is an async
// operation, so we need to wait for all the checks to complete before
// checking whether the queue is empty.
await waitForPendingRequestChecks()
// Because responding to one request may unblock additional requests,
// keep checking for more requests until the queue has settled.
const remaining = new Set<PendingRSCRequest>()
let actualResponses: Array<ExpectedResponseConfig> = []
let claimedExpectations = new Set<ExpectedResponseConfig>()
// Expectations that specify a `kind` and whose expected substring did
// appear in a response — but a response of the wrong kind, so it could
// not be claimed. Maps the expectation to the kind of the response the
// substring appeared in (undefined for responses with no prefetch kind,
// like navigations). Used to produce a more helpful error message when
// such an expectation ends up unclaimed.
const wrongKindMatches = new Map<
ExpectedResponseConfig,
ResponseKind | undefined
>()
while (batch.pendingRequests.size > 0) {
const pending = batch.pendingRequests
batch.pendingRequests = new Set()
for (const item of pending) {
const route = item.route
const url = item.url
let shouldBlock = false
const fulfilled = await item.result
if (item.didProcess) {
// This response was already processed by an inner `act` call.
} else {
item.didProcess = true
if (!item.isAppShell && expectedResponses === null) {
error.message = `
Expected no network requests to be initiated.
URL: ${url}
Headers: ${JSON.stringify(fulfilled.headers)}
Response:
${fulfilled.body}
`
throw error
}
// The error-status check applies to all requests, including App
// Shell requests — a 4xx/5xx App Shell is a real failure.
if (
fulfilled.status >= 400 &&
(allowStatuses === null ||
!allowStatuses.includes(fulfilled.status))
) {
error.message = `
Received a response with an error status code.
Status: ${fulfilled.status}
URL: ${url}
Headers: ${JSON.stringify(fulfilled.headers)}
Response:
${fulfilled.body}
`
throw error
}
if (!item.isAppShell && forbiddenResponses !== null) {
for (const forbiddenResponse of forbiddenResponses) {
// Like expectations, a rejection with `kind` only applies to
// responses of that kind: the same content may legitimately
// arrive in a response of a different kind (e.g. a test that
// asserts shell content arrives statically rejects it at
// kind: 'runtime' while claiming it at kind: 'static').
if (
forbiddenResponse.kind !== undefined &&
forbiddenResponse.kind !== item.kind
) {
continue
}
const includes = forbiddenResponse.includes
if (fulfilled.body.includes(includes)) {
error.message = `
Received a response containing an unexpected substring:
Rejected substring: ${includes}${
forbiddenResponse.kind !== undefined
? `\nRejected kind: '${forbiddenResponse.kind}'`
: ''
}
Response:
${fulfilled.body}
`
throw error
}
}
}
if (!item.isAppShell && expectedResponses !== null) {
// Check if this response matches any of the expectations.
//
//
// The same response may match multiple expectations, but within
// that response the expected strings must appear in order. So
// once something matches, keep track of the remaining
// response body.
const entireResponseBody = fulfilled.body
let remainingUnclaimedBody = entireResponseBody
// If the response doesn't match any of the expectations, that's
// fine. If it does match an expectation, but the only thing
// it matches is an expectation that was already claimed, then
// that's an error — each occurrence of an expectation must be
// given separately.
let responseWasClaimed = false
let firstAlreadyClaimedMatch: ExpectedResponseConfig | null = null
for (const expectedResponse of expectedResponses) {
const includes = expectedResponse.includes
const block = expectedResponse.block
if (!claimedExpectations.has(expectedResponse)) {
if (
expectedResponse.kind !== undefined &&
expectedResponse.kind !== item.kind
) {
// This expectation can only be claimed by a response of a
// specific kind, and this response is a different kind. If
// the expected substring nevertheless appears in this
// response, remember that so we can include it in the
// error message if the expectation ends up unclaimed.
if (
!wrongKindMatches.has(expectedResponse) &&
entireResponseBody.includes(includes)
) {
wrongKindMatches.set(expectedResponse, item.kind)
}
// Skip the duplicate-match check below — a wrong-kind
// response is not a duplicate occurrence.
continue
}
// This expectation was not already claimed. Check if we
// can claim it.
if (remainingUnclaimedBody.includes(includes)) {
// Match.
responseWasClaimed = true
// Remove everything up to and including the first
// occurrence of the matched substring.
remainingUnclaimedBody = remainingUnclaimedBody.slice(
remainingUnclaimedBody.indexOf(includes) + includes.length
)
claimedExpectations.add(expectedResponse)
actualResponses.push(expectedResponse)
if (block) {
shouldBlock = true
}
continue
}
}
// This expectation was already claimed, but let's check if the
// same string occurs later, too. If it does, it implies that
// the server sent the same string multiple times. This is fine
// as long as there's a separate expectation for
// each occurrence.
//
// Like the unclaimed path above, a kind-scoped expectation
// only applies to responses of that kind: its substring
// appearing in a response of a different kind is not a
// duplicate occurrence. (E.g. shell content claimed at
// kind: 'static' may legitimately appear again in the
// runtime fallback response.)
if (
expectedResponse.kind !== undefined &&
expectedResponse.kind !== item.kind
) {
continue
}
if (
firstAlreadyClaimedMatch === null &&
remainingUnclaimedBody.includes(includes)
) {
firstAlreadyClaimedMatch = expectedResponse
}
}
if (!responseWasClaimed && firstAlreadyClaimedMatch !== null) {
// This response did not match any of the _unclaimed_
// expecations, but it did match something that had already
// been claimed by an earlier response. This is an error —
// if the same expectation matches multiple times, you must
// list out a separate expectation for each occurrence.
error.message = `
The same expected substring was sent multiple times by the server:
${firstAlreadyClaimedMatch.includes}
Choose a more specific substring to assert on.
`
throw error
}
}
}
if (shouldBlock || shouldBlockAll) {
// This response was blocked by the `block` option. Don't
// fulfill it yet.
remaining.add(item)
if (route === null) {
error.message = `
The "block" option is not supported for requests that are redirected.
URL: ${url}
Headers: ${JSON.stringify(fulfilled.headers)}
Response:
${fulfilled.body}
`
throw error
}
} else {
if (route !== null) {
const request = route.request()
await route.fulfill({
body: fulfilled.body,
headers: fulfilled.headers,
status: fulfilled.status,
})
const browserResponse = await request.response()
if (browserResponse !== null) {
// For error responses (>= 400), the browser may not consume the body
// in the same way, so we skip waiting for finished() to avoid hanging
if (fulfilled.status < 400) {
await browserResponse.finished()
}
}
}
}
if (fulfilled.status === 307 || fulfilled.status === 308) {
// When fulfilling a redirect, for some reason, the page.route()
// handler installed earlier will not intercept the
// redirect request. Install a one-off event listener to wait for
// the redirected request to finish. This works for this case
// because we don't need to modify to delay the response; we only
// need to observe when it has finished.
// TODO: Because this request cannot be intercepted, it's
// incompatible with the "block" option. I haven't yet figured out
// a strategy to make that work. In the meantime, attempting to
// write a test that blocks a redirect will result in an error
// (see error above).
await new Promise<void>((resolve, reject) => {
page.once('request', (req) => {
const handleResponse = (res: Playwright.Response) => {
if (res.url() === req.url()) {
batch.pendingRequests.add({
url: req.url(),
route: null,
result: (async () => {
return {
// For redirects, body may not be available, so catch
// the error and return an empty string.
text: await res.text().catch(() => ''),
body: await res.body().catch(() => Buffer.from('')),
headers: res.headers(),
status: res.status(),
}
})(),
didProcess: false,
// The target of a redirect is a navigation, not an App
// Shell prefetch.
isAppShell: false,
// Navigations have no prefetch kind.
kind: undefined,
})
batch.didReceiveRouterRequest = true
page.off('response', handleResponse)
page.off('requestfailed', handleFailure)
resolve()
}
}
const handleFailure = (failedReq: Playwright.Request) => {
if (failedReq.url() === req.url()) {
page.off('response', handleResponse)
page.off('requestfailed', handleFailure)
error.message = `Request failed: ${failedReq.failure()?.errorText || 'Unknown error'}\n\nURL: ${req.url()}`
reject(error)
}
}
page.on('response', handleResponse)
page.on('requestfailed', handleFailure)
})
})
}
}
// After flushing the queue, wait for the microtask queue to be
// exhausted, then check if any additional requests are initiated. A
// single macrotask should be enough because if the router queue is
// network throttled, the next request is issued either directly within
// the task of the previous request's completion event, or in the
// microtask queue of that event.
await waitForIdleCallback()
await waitForPendingRequestChecks()
}
if (didHardNavigate) {
error.message =
'A hard navigation or refresh was triggerd during the `act` scope. ' +
'This is not supported.'
throw error
}
if (expectedResponses !== null) {
// Assert that the responses were received in the expected order
if (!equals(actualResponses, expectedResponses)) {
// Print a helpful error message.
if (expectedResponses.length === 1) {
error.message =
'Expected a response containing the given string:\n\n' +
expectedResponses[0].includes +
'\n'
} else {
const expectedSubstrings = expectedResponses.map(
(item) => item.includes
)
const actualSubstrings = actualResponses.map(
(item) => item.includes
)
error.message =
'Expected sequence of responses does not match:\n\n' +
diff(expectedSubstrings, actualSubstrings) +
'\n\n' +
'NOTE: Assertions are checked in order, so if an expectation ' +
'is missing, it may have actually appeared earlier in the ' +
'sequence than expected. Make sure the order is correct.'
}
// If any unclaimed expectation's substring did appear in a response,
// but a response of a different kind, call that out explicitly —
// it's the most likely explanation for the failure.
for (const [expectation, actualKind] of wrongKindMatches) {
if (claimedExpectations.has(expectation)) {
continue
}
const receivedIn =
actualKind === undefined
? 'a response with no prefetch kind (a navigation, Server ' +
'Action, or plain prefetch)'
: `a '${actualKind}' response`
error.message +=
'\n\n' +
`NOTE: The expected substring "${expectation.includes}" was ` +
`received in ${receivedIn}, but the expectation requires ` +
`kind: '${expectation.kind}'.`
}
throw error
}
}
// Some of the requests were blocked. Transfer them to the outer `act`
// batch so it can flush them.
if (remaining.size !== 0 && prevBatch !== null) {
for (const item of remaining) {
prevBatch.pendingRequests.add(item)
}
// The outer batch inherits pending work, so its "at least one
// request" check is satisfied.
prevBatch.didReceiveRouterRequest = true
}
return returnValue
} finally {
// Clean up
currentBatch = prevBatch
await page.unroute('**/*', routeHandler)
page.off('framedetached', hardNavigationHandler)
}
}
return act
}